@talqui-oss/template-builder 0.0.0-stage → 0.1.0-alpha.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.
package/dist/index.cjs ADDED
@@ -0,0 +1,1855 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ TemplateValidationError: () => TemplateValidationError,
24
+ UnsupportedByProviderError: () => UnsupportedByProviderError,
25
+ applyEdit: () => applyEdit,
26
+ changeCategory: () => changeCategory,
27
+ codes: () => codes,
28
+ defineAdapter: () => defineAdapter,
29
+ editorSections: () => editorSections,
30
+ editorTree: () => editorTree,
31
+ extractVariables: () => extractVariables,
32
+ previewTree: () => previewTree,
33
+ syncVariables: () => syncVariables,
34
+ toCurl: () => toCurl,
35
+ toPreviewModel: () => toPreviewModel,
36
+ toRequests: () => toRequests,
37
+ validate: () => validate
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+
41
+ // src/validate/text.ts
42
+ var PLACEHOLDER = /\{\{(.*?)\}\}/g;
43
+ var VARIABLE_NAME = /^[a-z][a-z0-9_]*$/;
44
+ var INVISIBLE = /[\p{Default_Ignorable_Code_Point}\u2800]/gu;
45
+ var THAI_LAO = /[\u0E00-\u0E7F]/g;
46
+ var CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu;
47
+ var EMOJI = /\p{Regional_Indicator}{2}|\p{Extended_Pictographic}(?:\uFE0F|\p{Emoji_Modifier})*(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\p{Emoji_Modifier})*)*/gu;
48
+ var MARKUP = /\*[^*\n]+\*|_[^_\n]+_|~[^~\n]+~|```[^`]+```/;
49
+ function placeholders(text) {
50
+ return [...text.matchAll(PLACEHOLDER)].map((match) => {
51
+ const name = match[1] ?? "";
52
+ return { raw: match[0], name, valid: VARIABLE_NAME.test(name) };
53
+ });
54
+ }
55
+ var length = (text) => [...text].length;
56
+ var countEmoji = (text) => text.match(EMOJI)?.length ?? 0;
57
+ var hasMarkup = (text) => text.includes("*") || MARKUP.test(text);
58
+ function countWords(text) {
59
+ const clean = text.replace(INVISIBLE, "");
60
+ const params = placeholders(clean).length;
61
+ const thai = clean.match(THAI_LAO)?.length ?? 0;
62
+ if (thai > 0) return Math.ceil(thai / 7) + params;
63
+ const cjk = clean.match(CJK)?.length ?? 0;
64
+ if (cjk > 0) return cjk + params;
65
+ return clean.match(/\S+/g)?.length ?? 0;
66
+ }
67
+ var EDGE_START = /^[\s*_~`]*\{\{[^}]*\}\}/;
68
+ var EDGE_END = /\{\{[^}]*\}\}[\s*_~`]*!*\.*\?*[\s*_~`]*$/;
69
+ var ADJACENT = /\{\{[^}]*\}\}[\s*_~`]*\{\{[^}]*\}\}/;
70
+ var startsOrEndsWithPlaceholder = (text) => EDGE_START.test(text) || EDGE_END.test(text);
71
+ var hasAdjacentPlaceholders = (text) => ADJACENT.test(text);
72
+
73
+ // src/variables.ts
74
+ function textFields(template) {
75
+ if (template.category === "AUTHENTICATION") return [];
76
+ const fields = [];
77
+ if (template.header?.type === "TEXT")
78
+ fields.push({ path: "header.text", text: template.header.text });
79
+ fields.push({ path: "body.text", text: template.body.text });
80
+ template.carousel?.cards.forEach((card, index) => {
81
+ if (card.body)
82
+ fields.push({
83
+ path: `carousel.cards.${index}.body.text`,
84
+ text: card.body.text
85
+ });
86
+ });
87
+ template.buttons?.forEach((button2, index) => {
88
+ if (button2.type === "URL")
89
+ fields.push({ path: `buttons.${index}.url`, text: button2.url });
90
+ });
91
+ template.carousel?.cards.forEach((card, cardIndex) => {
92
+ card.buttons?.forEach((button2, index) => {
93
+ if (button2.type === "URL")
94
+ fields.push({
95
+ path: `carousel.cards.${cardIndex}.buttons.${index}.url`,
96
+ text: button2.url
97
+ });
98
+ });
99
+ });
100
+ return fields;
101
+ }
102
+ var replacePlaceholders = (text, replace) => text.replace(/\{\{(.*?)\}\}/g, (_, name) => replace(name));
103
+ function extractVariables(template) {
104
+ const names = textFields(template).flatMap(
105
+ (field) => placeholders(field.text).map((p) => p.name)
106
+ );
107
+ return [...new Set(names)];
108
+ }
109
+
110
+ // src/validate/authentication.ts
111
+ var ANDROID_PACKAGE = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/;
112
+ var SIGNATURE_HASH = /^[a-zA-Z0-9/+=]{11}$/;
113
+ var URL_LIKE = /(https?:\/\/|www\.)\S+|\b[\w-]+\.[a-z]{2,6}\b/i;
114
+ function validateAuthentication(template, { report }) {
115
+ const minutes = template.footer?.codeExpirationMinutes;
116
+ if (minutes !== void 0 && (!Number.isInteger(minutes) || minutes < 1 || minutes > 90)) {
117
+ report(
118
+ "OTP_CODE_EXPIRATION_RANGE",
119
+ "footer.codeExpirationMinutes",
120
+ "The code expiration must be a whole number of minutes from 1 to 90."
121
+ );
122
+ }
123
+ const { button: button2 } = template;
124
+ for (const field of ["text", "autofillText"]) {
125
+ const text = button2[field];
126
+ if (text === void 0) continue;
127
+ if (length(text) > 25)
128
+ report(
129
+ "OTP_BUTTON_TEXT_TOO_LONG",
130
+ `button.${field}`,
131
+ "OTP button text can have at most 25 characters."
132
+ );
133
+ if (URL_LIKE.test(text))
134
+ report(
135
+ "OTP_BUTTON_TEXT_URL",
136
+ `button.${field}`,
137
+ "OTP button text cannot contain a link."
138
+ );
139
+ }
140
+ if (button2.otpType === "COPY_CODE") return;
141
+ const apps = button2.supportedApps ?? [];
142
+ if (apps.length === 0)
143
+ report(
144
+ "OTP_SUPPORTED_APPS_REQUIRED",
145
+ "button.supportedApps",
146
+ `${button2.otpType} buttons need at least one Android app (package name and signature hash).`
147
+ );
148
+ if (apps.length > 5)
149
+ report(
150
+ "OTP_SUPPORTED_APPS_TOO_MANY",
151
+ "button.supportedApps",
152
+ "An OTP button can list at most 5 apps."
153
+ );
154
+ const seen = /* @__PURE__ */ new Set();
155
+ apps.forEach((app, index) => {
156
+ const at = `button.supportedApps.${index}`;
157
+ if (!ANDROID_PACKAGE.test(app.packageName) || app.packageName.length > 224) {
158
+ report(
159
+ "OTP_PACKAGE_NAME_INVALID",
160
+ `${at}.packageName`,
161
+ "Use the Android package name, e.g. com.example.app (at most 224 characters)."
162
+ );
163
+ }
164
+ if (!SIGNATURE_HASH.test(app.signatureHash)) {
165
+ report(
166
+ "OTP_SIGNATURE_HASH_INVALID",
167
+ `${at}.signatureHash`,
168
+ "The app signature hash has exactly 11 characters (letters, numbers, +, / or =)."
169
+ );
170
+ }
171
+ const key = `${app.packageName}:${app.signatureHash}`;
172
+ if (seen.has(key))
173
+ report("OTP_SUPPORTED_APP_DUPLICATED", at, "This app is listed twice.");
174
+ seen.add(key);
175
+ });
176
+ if (button2.otpType === "ZERO_TAP" && button2.zeroTapTermsAccepted !== true) {
177
+ report(
178
+ "OTP_ZERO_TAP_TERMS_NOT_ACCEPTED",
179
+ "button.zeroTapTermsAccepted",
180
+ "Zero-tap buttons require `zeroTapTermsAccepted: true` to accept Meta's zero-tap terms."
181
+ );
182
+ }
183
+ }
184
+
185
+ // src/validate/codes.ts
186
+ var codes = {
187
+ SCHEMA_INVALID: "error",
188
+ NAME_INVALID: "error",
189
+ NAME_TOO_LONG: "error",
190
+ LANGUAGE_INVALID: "error",
191
+ CATEGORY_MISMATCH: "error",
192
+ TTL_OUT_OF_RANGE: "error",
193
+ PLACEHOLDER_INVALID: "error",
194
+ PLACEHOLDER_DUPLICATED: "error",
195
+ VARIABLE_NOT_DECLARED: "error",
196
+ VARIABLE_MISSING_EXAMPLE: "error",
197
+ VARIABLE_UNUSED: "warning",
198
+ PARAM_RATIO_TOO_HIGH: "error",
199
+ PARAM_ADJACENT: "error",
200
+ PARAM_AT_EDGE: "error",
201
+ HEADER_TEXT_REQUIRED: "error",
202
+ HEADER_TOO_LONG: "error",
203
+ HEADER_TOO_MANY_PARAMS: "error",
204
+ HEADER_NEWLINE: "error",
205
+ HEADER_EMOJI: "error",
206
+ HEADER_FORMATTING: "error",
207
+ HEADER_MEDIA_URL_INVALID: "error",
208
+ BODY_REQUIRED: "error",
209
+ BODY_TOO_LONG: "error",
210
+ BODY_MULTIPLE_NEWLINES: "error",
211
+ BODY_EMOJI_LIMIT: "error",
212
+ FOOTER_TOO_LONG: "error",
213
+ FOOTER_PARAMETER: "error",
214
+ FOOTER_NEWLINE: "error",
215
+ FOOTER_EMOJI: "error",
216
+ COMPONENT_NOT_ALLOWED: "error",
217
+ FOOTER_OPT_OUT_MISSING: "warning",
218
+ BUTTONS_TOO_MANY: "error",
219
+ BUTTONS_TYPE_LIMIT: "error",
220
+ QUICK_REPLIES_NOT_GROUPED: "error",
221
+ BUTTONS_PARTLY_HIDDEN: "warning",
222
+ BUTTONS_DESKTOP_UNSUPPORTED: "warning",
223
+ BUTTON_LABEL_REQUIRED: "error",
224
+ BUTTON_LABEL_TOO_LONG: "error",
225
+ BUTTON_LABEL_PARAMETER: "error",
226
+ BUTTON_LABEL_FORMATTING: "error",
227
+ BUTTON_LABEL_DUPLICATED: "error",
228
+ BUTTON_URL_INVALID: "error",
229
+ BUTTON_URL_TOO_LONG: "error",
230
+ BUTTON_URL_PARAM_POSITION: "error",
231
+ BUTTON_URL_WHATSAPP_DIRECT: "error",
232
+ BUTTON_URL_SHORTENED: "error",
233
+ BUTTON_URL_REDIRECT: "error",
234
+ BUTTON_PHONE_INVALID: "error",
235
+ BUTTON_COPY_CODE_EXAMPLE_REQUIRED: "error",
236
+ BUTTON_COPY_CODE_EXAMPLE_TOO_LONG: "error",
237
+ LTO_TEXT_REQUIRED: "error",
238
+ LTO_TEXT_TOO_LONG: "error",
239
+ LTO_URL_BUTTON_REQUIRED: "error",
240
+ CAROUSEL_CARDS_COUNT: "error",
241
+ CAROUSEL_CARDS_MISMATCH: "error",
242
+ CAROUSEL_CARD_MEDIA_URL_INVALID: "error",
243
+ CAROUSEL_CARD_BODY_REQUIRED: "error",
244
+ CAROUSEL_CARD_BODY_TOO_LONG: "error",
245
+ CAROUSEL_CARD_BODY_NEWLINES: "error",
246
+ CAROUSEL_CARD_BUTTONS_REQUIRED: "error",
247
+ CAROUSEL_CARD_BUTTONS_TOO_MANY: "error",
248
+ CALL_PERMISSION_NOT_COMBINABLE: "error",
249
+ ORDER_DETAILS_MISMATCH: "error",
250
+ BUTTON_ORDER_DETAILS_TEXT: "error",
251
+ OTP_CODE_EXPIRATION_RANGE: "error",
252
+ OTP_BUTTON_TEXT_TOO_LONG: "error",
253
+ OTP_BUTTON_TEXT_URL: "error",
254
+ OTP_SUPPORTED_APPS_REQUIRED: "error",
255
+ OTP_SUPPORTED_APPS_TOO_MANY: "error",
256
+ OTP_SUPPORTED_APP_DUPLICATED: "error",
257
+ OTP_PACKAGE_NAME_INVALID: "error",
258
+ OTP_SIGNATURE_HASH_INVALID: "error",
259
+ OTP_ZERO_TAP_TERMS_NOT_ACCEPTED: "error"
260
+ };
261
+
262
+ // src/validate/buttons.ts
263
+ var PER_TYPE_LIMIT = {
264
+ URL: 2,
265
+ PHONE_NUMBER: 1,
266
+ VOICE_CALL: 1,
267
+ COPY_CODE: 1,
268
+ FLOW: 1,
269
+ CATALOG: 1,
270
+ MPM: 1,
271
+ SPM: 1,
272
+ ORDER_DETAILS: 1,
273
+ PAYMENT_REQUEST: 3
274
+ };
275
+ var WHATSAPP_ALLOWED = [
276
+ "whatsapp.com/otp",
277
+ "wa.me/in/bill/reminder",
278
+ "wa.me/pay"
279
+ ];
280
+ var REDIRECT_PARAMS = [
281
+ "redirect",
282
+ "url",
283
+ "q",
284
+ "goto",
285
+ "out",
286
+ "away",
287
+ "external"
288
+ ];
289
+ function validateButtons(buttons, template, path, ctx, inCard = false) {
290
+ const { report } = ctx;
291
+ if (!inCard && buttons.length > 10)
292
+ report(
293
+ "BUTTONS_TOO_MANY",
294
+ path,
295
+ `A template can have at most 10 buttons; this one has ${buttons.length}.`
296
+ );
297
+ const counts = /* @__PURE__ */ new Map();
298
+ for (const button2 of buttons)
299
+ counts.set(button2.type, (counts.get(button2.type) ?? 0) + 1);
300
+ for (const [type, count2] of counts) {
301
+ const limit = PER_TYPE_LIMIT[type];
302
+ if (limit !== void 0 && count2 > limit)
303
+ report(
304
+ "BUTTONS_TYPE_LIMIT",
305
+ path,
306
+ `At most ${limit} ${type} button${limit > 1 ? "s" : ""} per template; found ${count2}.`
307
+ );
308
+ }
309
+ const quickReplies = buttons.map((b) => b.type === "QUICK_REPLY");
310
+ const firstQr = quickReplies.indexOf(true);
311
+ const lastQr = quickReplies.lastIndexOf(true);
312
+ if (firstQr >= 0 && quickReplies.slice(firstQr, lastQr + 1).includes(false)) {
313
+ report(
314
+ "QUICK_REPLIES_NOT_GROUPED",
315
+ path,
316
+ "Quick reply buttons must be next to each other, all before or all after the other buttons."
317
+ );
318
+ }
319
+ if (!inCard && buttons.length > 3)
320
+ report(
321
+ "BUTTONS_PARTLY_HIDDEN",
322
+ path,
323
+ 'With more than 3 buttons, WhatsApp shows 2 and puts the rest behind "See all options".'
324
+ );
325
+ if (!inCard && (buttons.length >= 4 || firstQr >= 0 && quickReplies.includes(false))) {
326
+ report(
327
+ "BUTTONS_DESKTOP_UNSUPPORTED",
328
+ path,
329
+ "Templates with 4 or more buttons, or quick replies mixed with other buttons, are not shown on WhatsApp Desktop."
330
+ );
331
+ }
332
+ if (counts.has("COPY_CODE") && template.category !== "MARKETING") {
333
+ report(
334
+ "CATEGORY_MISMATCH",
335
+ "category",
336
+ "Templates with a COPY_CODE button must use the MARKETING category."
337
+ );
338
+ }
339
+ const labels = /* @__PURE__ */ new Map();
340
+ buttons.forEach((button2, index) => {
341
+ const at = `${path}.${index}`;
342
+ if ("text" in button2 && button2.text !== void 0) {
343
+ validateLabel(button2.text, `${at}.text`, ctx);
344
+ if (button2.text !== "") {
345
+ if (labels.has(button2.text))
346
+ report(
347
+ "BUTTON_LABEL_DUPLICATED",
348
+ `${at}.text`,
349
+ `"${button2.text}" is already used by button ${labels.get(button2.text)}. Labels must be unique.`
350
+ );
351
+ else labels.set(button2.text, index);
352
+ }
353
+ }
354
+ if (button2.type === "URL")
355
+ validateUrl(button2.url, `${at}.url`, template, ctx);
356
+ if (button2.type === "PHONE_NUMBER" && !/^\+[1-9]\d{6,18}$/.test(button2.phoneNumber.replace(/[\s-]/g, ""))) {
357
+ report(
358
+ "BUTTON_PHONE_INVALID",
359
+ `${at}.phoneNumber`,
360
+ "Use the international format with country code, e.g. +15550100."
361
+ );
362
+ }
363
+ if (button2.type === "COPY_CODE") {
364
+ if (button2.example.trim() === "")
365
+ report(
366
+ "BUTTON_COPY_CODE_EXAMPLE_REQUIRED",
367
+ `${at}.example`,
368
+ "Add a sample coupon code."
369
+ );
370
+ else if (length(button2.example) > 20)
371
+ report(
372
+ "BUTTON_COPY_CODE_EXAMPLE_TOO_LONG",
373
+ `${at}.example`,
374
+ "Coupon codes can have at most 20 characters."
375
+ );
376
+ }
377
+ });
378
+ }
379
+ function validateLabel(text, path, { report }) {
380
+ if (text.trim() === "")
381
+ return report("BUTTON_LABEL_REQUIRED", path, "Button text is required.");
382
+ if (length(text) > 25)
383
+ report(
384
+ "BUTTON_LABEL_TOO_LONG",
385
+ path,
386
+ `Button text can have at most 25 characters; this one has ${length(text)}.`
387
+ );
388
+ if (placeholders(text).length > 0)
389
+ report(
390
+ "BUTTON_LABEL_PARAMETER",
391
+ path,
392
+ "Button text cannot contain variables."
393
+ );
394
+ if (hasMarkup(text) || /[_~`]/.test(text))
395
+ report(
396
+ "BUTTON_LABEL_FORMATTING",
397
+ path,
398
+ "Button text cannot contain *, _, ~ or ` characters."
399
+ );
400
+ }
401
+ function validateUrl(url, path, template, { report, options }) {
402
+ const params = placeholders(url);
403
+ if (params.length > 1 || params.length === 1 && !url.endsWith(params[0].raw)) {
404
+ report(
405
+ "BUTTON_URL_PARAM_POSITION",
406
+ path,
407
+ "A URL button can have one variable, and only at the very end."
408
+ );
409
+ }
410
+ if (length(url) > 2e3)
411
+ report(
412
+ "BUTTON_URL_TOO_LONG",
413
+ path,
414
+ "URLs can have at most 2000 characters."
415
+ );
416
+ const sample = params.reduce(
417
+ (text, p) => text.replace(p.raw, template.variables?.[p.name]?.example || "sample"),
418
+ url
419
+ );
420
+ let parsed;
421
+ try {
422
+ parsed = new URL(sample);
423
+ } catch {
424
+ return report(
425
+ "BUTTON_URL_INVALID",
426
+ path,
427
+ "Enter a full URL starting with https:// or http://."
428
+ );
429
+ }
430
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
431
+ return report(
432
+ "BUTTON_URL_INVALID",
433
+ path,
434
+ "URL buttons only accept http and https links."
435
+ );
436
+ }
437
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
438
+ const isWhatsapp = ["whatsapp.com", "wa.me"].some(
439
+ (domain) => host === domain || host.endsWith(`.${domain}`)
440
+ );
441
+ if (isWhatsapp && !WHATSAPP_ALLOWED.some(
442
+ (allowed) => `${host}${parsed.pathname}`.startsWith(allowed)
443
+ )) {
444
+ report(
445
+ "BUTTON_URL_WHATSAPP_DIRECT",
446
+ path,
447
+ "Links that open a WhatsApp chat (wa.me, whatsapp.com) are not allowed in URL buttons."
448
+ );
449
+ }
450
+ if (options.shortenerDomains.some(
451
+ (domain) => host === domain || host.endsWith(`.${domain}`)
452
+ )) {
453
+ report(
454
+ "BUTTON_URL_SHORTENED",
455
+ path,
456
+ `Shortened links (${host}) are rejected. Use the full destination URL.`
457
+ );
458
+ }
459
+ if (REDIRECT_PARAMS.some((param) => parsed.searchParams.has(param)) || /\/link$/.test(parsed.pathname) && parsed.searchParams.has("u")) {
460
+ report(
461
+ "BUTTON_URL_REDIRECT",
462
+ path,
463
+ "Links that redirect to another site through a query parameter are rejected. Link to the final page."
464
+ );
465
+ }
466
+ }
467
+
468
+ // src/validate/standard.ts
469
+ var EMOJI_LIMIT = { MARKETING: 10, UTILITY: 6 };
470
+ function validateStandard(template, ctx) {
471
+ const { report } = ctx;
472
+ const { header: header2, body, footer, buttons, limitedTimeOffer, carousel } = template;
473
+ if (header2) {
474
+ if (header2.type === "TEXT") {
475
+ const text = header2.text;
476
+ if (text.trim() === "")
477
+ report(
478
+ "HEADER_TEXT_REQUIRED",
479
+ "header.text",
480
+ "Header text is required, or remove the header."
481
+ );
482
+ if (length(text) > 60)
483
+ report(
484
+ "HEADER_TOO_LONG",
485
+ "header.text",
486
+ `Header text can have at most 60 characters; this one has ${length(text)}.`
487
+ );
488
+ if (placeholders(text).length > 1)
489
+ report(
490
+ "HEADER_TOO_MANY_PARAMS",
491
+ "header.text",
492
+ "A text header can have at most one variable."
493
+ );
494
+ if (text.includes("\n"))
495
+ report(
496
+ "HEADER_NEWLINE",
497
+ "header.text",
498
+ "Header text cannot have line breaks."
499
+ );
500
+ if (countEmoji(text) > 0)
501
+ report(
502
+ "HEADER_EMOJI",
503
+ "header.text",
504
+ "Header text cannot have emojis."
505
+ );
506
+ if (hasMarkup(text))
507
+ report(
508
+ "HEADER_FORMATTING",
509
+ "header.text",
510
+ "Header text cannot use formatting such as *bold* or a literal asterisk."
511
+ );
512
+ } else if ("url" in header2 && !isHttpUrl(header2.url)) {
513
+ report(
514
+ "HEADER_MEDIA_URL_INVALID",
515
+ "header.url",
516
+ "Add a public https:// URL to a sample file."
517
+ );
518
+ }
519
+ }
520
+ if (body.text.trim() === "") {
521
+ report("BODY_REQUIRED", "body.text", "Body text is required.");
522
+ } else {
523
+ const limit = limitedTimeOffer ? 600 : 1024;
524
+ if (length(body.text) > limit)
525
+ report(
526
+ "BODY_TOO_LONG",
527
+ "body.text",
528
+ `Body text can have at most ${limit} characters; this one has ${length(body.text)}.`
529
+ );
530
+ if (/\n{3,}/.test(body.text))
531
+ report(
532
+ "BODY_MULTIPLE_NEWLINES",
533
+ "body.text",
534
+ "Body text cannot have more than one blank line in a row."
535
+ );
536
+ const emoji = countEmoji(body.text);
537
+ const emojiLimit = EMOJI_LIMIT[template.category];
538
+ if (emoji > emojiLimit)
539
+ report(
540
+ "BODY_EMOJI_LIMIT",
541
+ "body.text",
542
+ `${template.category} templates can have at most ${emojiLimit} emojis in the body; this one has ${emoji}.`
543
+ );
544
+ validateParams(body.text, "body.text", ctx);
545
+ }
546
+ if (footer) {
547
+ if (limitedTimeOffer)
548
+ report(
549
+ "COMPONENT_NOT_ALLOWED",
550
+ "footer",
551
+ "Limited-time offer templates cannot have a footer."
552
+ );
553
+ if (length(footer.text) > 60)
554
+ report(
555
+ "FOOTER_TOO_LONG",
556
+ "footer.text",
557
+ `Footer text can have at most 60 characters; this one has ${length(footer.text)}.`
558
+ );
559
+ if (placeholders(footer.text).length > 0)
560
+ report(
561
+ "FOOTER_PARAMETER",
562
+ "footer.text",
563
+ "Footer text cannot contain variables."
564
+ );
565
+ if (footer.text.includes("\n"))
566
+ report(
567
+ "FOOTER_NEWLINE",
568
+ "footer.text",
569
+ "Footer text cannot have line breaks."
570
+ );
571
+ if (countEmoji(footer.text) > 0)
572
+ report("FOOTER_EMOJI", "footer.text", "Footer text cannot have emojis.");
573
+ } else if (template.category === "MARKETING" && !limitedTimeOffer && !carousel) {
574
+ report(
575
+ "FOOTER_OPT_OUT_MISSING",
576
+ "footer",
577
+ 'Marketing templates are less likely to be blocked with an opt-out footer, e.g. "Reply STOP to opt out".'
578
+ );
579
+ }
580
+ if (buttons) validateButtons(buttons, template, "buttons", ctx);
581
+ if (limitedTimeOffer) {
582
+ if (template.category !== "MARKETING")
583
+ report(
584
+ "CATEGORY_MISMATCH",
585
+ "category",
586
+ "Limited-time offer templates must use the MARKETING category."
587
+ );
588
+ const text = limitedTimeOffer.text;
589
+ const limit = ctx.options.limitedTimeOfferTextLimit;
590
+ if (text.trim() === "")
591
+ report(
592
+ "LTO_TEXT_REQUIRED",
593
+ "limitedTimeOffer.text",
594
+ "Add the offer headline."
595
+ );
596
+ else if (length(text) > limit)
597
+ report(
598
+ "LTO_TEXT_TOO_LONG",
599
+ "limitedTimeOffer.text",
600
+ `The offer headline can have at most ${limit} characters.`
601
+ );
602
+ if (header2 && header2.type !== "IMAGE" && header2.type !== "VIDEO")
603
+ report(
604
+ "COMPONENT_NOT_ALLOWED",
605
+ "header",
606
+ "Limited-time offer templates only accept an IMAGE or VIDEO header."
607
+ );
608
+ if (!buttons?.some((b) => b.type === "URL"))
609
+ report(
610
+ "LTO_URL_BUTTON_REQUIRED",
611
+ "buttons",
612
+ "Limited-time offer templates need a URL button."
613
+ );
614
+ }
615
+ if (carousel) validateCarousel(carousel.cards, template, ctx);
616
+ if (template.callPermissionRequest && (buttons?.length || carousel || limitedTimeOffer)) {
617
+ report(
618
+ "CALL_PERMISSION_NOT_COMBINABLE",
619
+ "callPermissionRequest",
620
+ "Call permission requests cannot be combined with buttons, carousels or limited-time offers."
621
+ );
622
+ }
623
+ const orderButtons = buttons?.filter((b) => b.type === "ORDER_DETAILS").length ?? 0;
624
+ if (template.displayFormat === "ORDER_DETAILS" !== (orderButtons === 1)) {
625
+ report(
626
+ "ORDER_DETAILS_MISMATCH",
627
+ "displayFormat",
628
+ 'Order details templates need `displayFormat: "ORDER_DETAILS"` and exactly one ORDER_DETAILS button.'
629
+ );
630
+ }
631
+ buttons?.forEach((b, index) => {
632
+ if (b.type === "ORDER_DETAILS" && b.text !== "Copy Pix code")
633
+ report(
634
+ "BUTTON_ORDER_DETAILS_TEXT",
635
+ `buttons.${index}.text`,
636
+ 'The ORDER_DETAILS button text is fixed by WhatsApp: use "Copy Pix code".'
637
+ );
638
+ });
639
+ }
640
+ function validateParams(text, path, { report }) {
641
+ const params = placeholders(text).length;
642
+ const words = countWords(text);
643
+ if (params > 0 && words > 0 && params / words > 0.33333333) {
644
+ report(
645
+ "PARAM_RATIO_TOO_HIGH",
646
+ path,
647
+ `Too many variables for the amount of text: ${params} variable${params > 1 ? "s" : ""} in ${words} words. Each variable needs at least 3 other words.`
648
+ );
649
+ }
650
+ if (hasAdjacentPlaceholders(text))
651
+ report(
652
+ "PARAM_ADJACENT",
653
+ path,
654
+ "Two variables cannot be next to each other. Put some text between them."
655
+ );
656
+ if (startsOrEndsWithPlaceholder(text))
657
+ report(
658
+ "PARAM_AT_EDGE",
659
+ path,
660
+ "The body cannot start or end with a variable."
661
+ );
662
+ }
663
+ function validateCarousel(cards, template, ctx) {
664
+ const { report, options } = ctx;
665
+ if (template.category !== "MARKETING")
666
+ report(
667
+ "CATEGORY_MISMATCH",
668
+ "category",
669
+ "Carousel templates must use the MARKETING category."
670
+ );
671
+ if (cards.length < 2 || cards.length > 10)
672
+ report(
673
+ "CAROUSEL_CARDS_COUNT",
674
+ "carousel.cards",
675
+ `A carousel needs 2 to 10 cards; this one has ${cards.length}.`
676
+ );
677
+ for (const field of ["header", "footer", "buttons"]) {
678
+ if (template[field])
679
+ report(
680
+ "COMPONENT_NOT_ALLOWED",
681
+ field,
682
+ `Carousel templates cannot have a top-level ${field}; put it in the cards.`
683
+ );
684
+ }
685
+ const shape = (card) => [
686
+ card.header.type,
687
+ Boolean(card.body),
688
+ (card.buttons ?? []).map((b) => b.type).join(",")
689
+ ].join("|");
690
+ const first = cards[0] ? shape(cards[0]) : "";
691
+ cards.forEach((card, index) => {
692
+ const at = `carousel.cards.${index}`;
693
+ if (index > 0 && shape(card) !== first)
694
+ report(
695
+ "CAROUSEL_CARDS_MISMATCH",
696
+ at,
697
+ "Every card needs the same header type, the same parts and the same buttons, in the same order."
698
+ );
699
+ if ("url" in card.header && !isHttpUrl(card.header.url))
700
+ report(
701
+ "CAROUSEL_CARD_MEDIA_URL_INVALID",
702
+ `${at}.header.url`,
703
+ "Add a public https:// URL to a sample image or video."
704
+ );
705
+ const text = card.body?.text ?? "";
706
+ if (text.trim() === "")
707
+ report(
708
+ "CAROUSEL_CARD_BODY_REQUIRED",
709
+ `${at}.body.text`,
710
+ "Each card needs body text."
711
+ );
712
+ if (length(text) > options.carouselCardBodyLimit)
713
+ report(
714
+ "CAROUSEL_CARD_BODY_TOO_LONG",
715
+ `${at}.body.text`,
716
+ `Card text can have at most ${options.carouselCardBodyLimit} characters.`
717
+ );
718
+ if ((text.match(/\n/g)?.length ?? 0) > 2)
719
+ report(
720
+ "CAROUSEL_CARD_BODY_NEWLINES",
721
+ `${at}.body.text`,
722
+ "Card text can have at most 2 line breaks."
723
+ );
724
+ const buttons = card.buttons ?? [];
725
+ if (buttons.length === 0)
726
+ report(
727
+ "CAROUSEL_CARD_BUTTONS_REQUIRED",
728
+ `${at}.buttons`,
729
+ "Each card needs at least one button."
730
+ );
731
+ if (buttons.length > 2)
732
+ report(
733
+ "CAROUSEL_CARD_BUTTONS_TOO_MANY",
734
+ `${at}.buttons`,
735
+ "Each card can have at most 2 buttons."
736
+ );
737
+ validateButtons(buttons, template, `${at}.buttons`, ctx, true);
738
+ });
739
+ }
740
+ function isHttpUrl(value) {
741
+ try {
742
+ return ["https:", "http:"].includes(new URL(value).protocol);
743
+ } catch {
744
+ return false;
745
+ }
746
+ }
747
+
748
+ // src/validate/index.ts
749
+ var SHORTENERS = [
750
+ "bit.ly",
751
+ "goo.gl",
752
+ "tinyurl.com",
753
+ "ow.ly",
754
+ "short.url",
755
+ "shorte.st",
756
+ "short.gs"
757
+ ];
758
+ var TTL_RANGE = {
759
+ AUTHENTICATION: [30, 900],
760
+ UTILITY: [30, 43200],
761
+ MARKETING: [43200, 2592e3]
762
+ };
763
+ var CATEGORIES = ["MARKETING", "UTILITY", "AUTHENTICATION"];
764
+ function validate(template, options = {}) {
765
+ const issues = [];
766
+ const disabled = new Set(options.disable);
767
+ const ctx = {
768
+ report: (code, path, message) => {
769
+ if (!disabled.has(code))
770
+ issues.push({ code, path, message, severity: codes[code] });
771
+ },
772
+ options: {
773
+ shortenerDomains: options.shortenerDomains ?? SHORTENERS,
774
+ limitedTimeOfferTextLimit: options.limitedTimeOfferTextLimit ?? 16,
775
+ carouselCardBodyLimit: options.carouselCardBodyLimit ?? 160
776
+ }
777
+ };
778
+ const shapeError = checkShape(template);
779
+ if (shapeError) {
780
+ ctx.report("SCHEMA_INVALID", shapeError.path, shapeError.message);
781
+ return { valid: false, issues };
782
+ }
783
+ const { report } = ctx;
784
+ if (!/^[a-z0-9_]+$/.test(template.name))
785
+ report(
786
+ "NAME_INVALID",
787
+ "name",
788
+ "Use only lowercase letters, numbers and underscores, e.g. order_shipped."
789
+ );
790
+ if (template.name.length > 512)
791
+ report(
792
+ "NAME_TOO_LONG",
793
+ "name",
794
+ "The name can have at most 512 characters."
795
+ );
796
+ if (!/^[a-z]{2,3}(_[A-Z]{2})?$/.test(template.language))
797
+ report(
798
+ "LANGUAGE_INVALID",
799
+ "language",
800
+ 'Use a locale code such as "en_US", "pt_BR" or "es".'
801
+ );
802
+ const ttl = template.messageSendTtlSeconds;
803
+ if (ttl !== void 0) {
804
+ const [min, max] = TTL_RANGE[template.category];
805
+ if (ttl < min || ttl > max)
806
+ report(
807
+ "TTL_OUT_OF_RANGE",
808
+ "messageSendTtlSeconds",
809
+ `For ${template.category} templates the TTL must be between ${min} and ${max} seconds.`
810
+ );
811
+ }
812
+ if (template.category === "AUTHENTICATION")
813
+ validateAuthentication(template, ctx);
814
+ else {
815
+ validateVariables(template, ctx);
816
+ validateStandard(template, ctx);
817
+ }
818
+ return { valid: !issues.some((issue) => issue.severity === "error"), issues };
819
+ }
820
+ function validateVariables(template, { report }) {
821
+ if (template.category === "AUTHENTICATION") return;
822
+ const declared = template.variables ?? {};
823
+ const used = /* @__PURE__ */ new Set();
824
+ for (const field of textFields(template)) {
825
+ const seen = /* @__PURE__ */ new Set();
826
+ for (const p of placeholders(field.text)) {
827
+ if (!p.valid || p.name.length > 32) {
828
+ report(
829
+ "PLACEHOLDER_INVALID",
830
+ field.path,
831
+ `"${p.raw}" is not a valid variable. Use a name with lowercase letters, numbers and underscores (up to 32), e.g. {{first_name}}.`
832
+ );
833
+ continue;
834
+ }
835
+ if (seen.has(p.name))
836
+ report(
837
+ "PLACEHOLDER_DUPLICATED",
838
+ field.path,
839
+ `"${p.raw}" appears twice in the same text. Use a second variable instead.`
840
+ );
841
+ seen.add(p.name);
842
+ used.add(p.name);
843
+ if (!(p.name in declared))
844
+ report(
845
+ "VARIABLE_NOT_DECLARED",
846
+ `variables.${p.name}`,
847
+ `Add an example for "${p.name}" in variables.`
848
+ );
849
+ }
850
+ }
851
+ for (const [name, variable] of Object.entries(declared)) {
852
+ if (!variable.example?.trim())
853
+ report(
854
+ "VARIABLE_MISSING_EXAMPLE",
855
+ `variables.${name}.example`,
856
+ `Add an example value for "${name}".`
857
+ );
858
+ if (!used.has(name))
859
+ report(
860
+ "VARIABLE_UNUSED",
861
+ `variables.${name}`,
862
+ `"${name}" is declared but not used in the template.`
863
+ );
864
+ }
865
+ }
866
+ function checkShape(template) {
867
+ const t = template;
868
+ if (typeof t !== "object" || t === null)
869
+ return { path: "", message: "A template must be an object." };
870
+ for (const field of ["name", "language"]) {
871
+ if (typeof t[field] !== "string")
872
+ return { path: field, message: `"${field}" must be a string.` };
873
+ }
874
+ if (!CATEGORIES.includes(t.category))
875
+ return {
876
+ path: "category",
877
+ message: "category must be MARKETING, UTILITY or AUTHENTICATION."
878
+ };
879
+ if (t.category === "AUTHENTICATION") {
880
+ const button2 = t.button;
881
+ if (button2?.type !== "OTP")
882
+ return {
883
+ path: "button",
884
+ message: 'Authentication templates need one button: { type: "OTP", otpType }.'
885
+ };
886
+ return void 0;
887
+ }
888
+ if (typeof t.body?.text !== "string")
889
+ return { path: "body.text", message: "body.text must be a string." };
890
+ const buttons = t.buttons ?? [];
891
+ if (!Array.isArray(buttons) || buttons.some((b) => typeof b?.type !== "string"))
892
+ return {
893
+ path: "buttons",
894
+ message: "buttons must be an array of objects with a type."
895
+ };
896
+ return void 0;
897
+ }
898
+
899
+ // src/requests.ts
900
+ var TemplateValidationError = class extends Error {
901
+ constructor(issues) {
902
+ super(
903
+ `Template is invalid: ${issues.map((i) => `${i.code} at ${i.path || "template"}`).join(", ")}`
904
+ );
905
+ this.issues = issues;
906
+ this.name = "TemplateValidationError";
907
+ }
908
+ issues;
909
+ };
910
+ function toRequests(template, adapter, options) {
911
+ const { issues } = validate(template, options);
912
+ const errors = issues.filter((issue) => issue.severity === "error");
913
+ if (errors.length > 0) throw new TemplateValidationError(errors);
914
+ return adapter.toRequests(template);
915
+ }
916
+ var doubleQuoted = (value) => `"${value.replace(/["\\`]/g, "\\$&")}"`;
917
+ var singleQuoted = (value) => `'${value.replace(/'/g, "'\\''").replace(/"(\$[A-Z][A-Z0-9_]*)"/g, `"'"$1"'"`)}'`;
918
+ function toCurl(requests) {
919
+ return [requests].flat().map(
920
+ (request) => [
921
+ ...(request.comments ?? []).map((line) => `# ${line}`),
922
+ [
923
+ `curl -X ${request.method} ${doubleQuoted(request.url)}`,
924
+ ...Object.entries(request.headers).map(
925
+ ([name, value]) => ` -H ${doubleQuoted(`${name}: ${value}`)}`
926
+ ),
927
+ ` -d ${singleQuoted(JSON.stringify(request.body, null, 2))}`
928
+ ].join(" \\\n")
929
+ ].join("\n")
930
+ ).join("\n\n");
931
+ }
932
+
933
+ // src/adapters/types.ts
934
+ function defineAdapter(definition) {
935
+ return (...[options]) => ({
936
+ name: definition.name,
937
+ toRequests: (template) => definition.toRequests(template, options ?? {})
938
+ });
939
+ }
940
+ var UnsupportedByProviderError = class extends Error {
941
+ constructor(provider, path, message) {
942
+ super(`${provider}: ${message} (${path})`);
943
+ this.provider = provider;
944
+ this.path = path;
945
+ this.name = "UnsupportedByProviderError";
946
+ }
947
+ provider;
948
+ path;
949
+ code = "UNSUPPORTED_BY_PROVIDER";
950
+ };
951
+
952
+ // src/preview/format.ts
953
+ var MARKERS = [
954
+ ["```", "mono"],
955
+ ["*", "bold"],
956
+ ["_", "italic"],
957
+ ["~", "strike"],
958
+ ["`", "code"]
959
+ ];
960
+ var TOKEN = 57344;
961
+ var isToken = (char = "") => {
962
+ const code = char.charCodeAt(0);
963
+ return code >= TOKEN && code < TOKEN + 4096;
964
+ };
965
+ var isWordChar = (char = "") => /[\p{L}\p{N}]/u.test(char) || isToken(char);
966
+ var isSpace = (char = "") => /\s/.test(char);
967
+ function parseText(text, resolve) {
968
+ const names = [];
969
+ const tokenized = text.replace(
970
+ /\{\{(.*?)\}\}/g,
971
+ (_, name) => String.fromCharCode(TOKEN + names.push(name) - 1)
972
+ );
973
+ const toSpans = (source) => inline(source, {}).flatMap((span2) => expandTokens(span2, names, resolve));
974
+ const lines2 = [];
975
+ const rows = tokenized.split("\n");
976
+ for (let i = 0; i < rows.length; i++) {
977
+ const row = rows[i];
978
+ if (row.startsWith("```") && !row.slice(3).includes("```")) {
979
+ const end = rows.findIndex((r, j) => j > i && r.endsWith("```"));
980
+ if (end !== -1) {
981
+ const block = [
982
+ row.slice(3),
983
+ ...rows.slice(i + 1, end),
984
+ rows[end].slice(0, -3)
985
+ ];
986
+ lines2.push({
987
+ kind: "code",
988
+ text: detokenize(block.join("\n"), names, resolve)
989
+ });
990
+ i = end;
991
+ continue;
992
+ }
993
+ }
994
+ const numbered = /^(\d+)\. (.*)$/.exec(row);
995
+ if (numbered)
996
+ lines2.push({
997
+ kind: "numbered",
998
+ marker: `${numbered[1]}.`,
999
+ spans: toSpans(numbered[2])
1000
+ });
1001
+ else if (/^[-*] /.test(row))
1002
+ lines2.push({ kind: "bullet", spans: toSpans(row.slice(2)) });
1003
+ else if (row.startsWith("> "))
1004
+ lines2.push({ kind: "quote", spans: toSpans(row.slice(2)) });
1005
+ else lines2.push({ kind: "paragraph", spans: toSpans(row) });
1006
+ }
1007
+ return lines2;
1008
+ }
1009
+ function inline(text, marks) {
1010
+ for (let i = 0; i < text.length; i++) {
1011
+ for (const [marker, mark] of MARKERS) {
1012
+ if (!text.startsWith(marker, i) || marks[mark]) continue;
1013
+ const close = findClose(text, i, marker);
1014
+ if (close === -1) continue;
1015
+ const inner = text.slice(i + marker.length, close);
1016
+ const content = mark === "mono" || mark === "code" ? [{ text: inner, ...marks, [mark]: true }] : inline(inner, { ...marks, [mark]: true });
1017
+ return [
1018
+ ...plain(text.slice(0, i), marks),
1019
+ ...content,
1020
+ ...inline(text.slice(close + marker.length), marks)
1021
+ ];
1022
+ }
1023
+ }
1024
+ return plain(text, marks);
1025
+ }
1026
+ function findClose(text, open, marker) {
1027
+ const before = text[open - 1];
1028
+ const first = text[open + marker.length];
1029
+ if (isWordChar(before) || first === void 0 || isSpace(first)) return -1;
1030
+ for (let j = open + marker.length + 1; j <= text.length - marker.length; j++) {
1031
+ if (!text.startsWith(marker, j)) continue;
1032
+ if (!isSpace(text[j - 1]) && !isWordChar(text[j + marker.length])) return j;
1033
+ }
1034
+ return -1;
1035
+ }
1036
+ var plain = (text, marks) => text ? [{ text, ...marks }] : [];
1037
+ function expandTokens(span2, names, resolve) {
1038
+ return [...span2.text.split(/([\uE000-\uEFFF])/u)].filter(Boolean).map((part) => {
1039
+ if (!isToken(part)) return { ...span2, text: part };
1040
+ const name = names[part.charCodeAt(0) - TOKEN];
1041
+ return { ...span2, text: resolve(name), variable: name };
1042
+ });
1043
+ }
1044
+ var detokenize = (text, names, resolve) => text.replace(
1045
+ /[\uE000-\uEFFF]/gu,
1046
+ (char) => resolve(names[char.charCodeAt(0) - TOKEN])
1047
+ );
1048
+
1049
+ // src/preview/locale.ts
1050
+ var LOCALES = {
1051
+ en: {
1052
+ authenticationBody: "*{code}* is your verification code.",
1053
+ securityRecommendation: "For your security, do not share this code.",
1054
+ codeExpiration: "This code expires in {minutes} minutes.",
1055
+ copyCode: "Copy code",
1056
+ copyOfferCode: "Copy offer code",
1057
+ seeAllOptions: "See all options"
1058
+ },
1059
+ // Checked against messages received on an iPhone.
1060
+ pt: {
1061
+ authenticationBody: "Seu c\xF3digo de verifica\xE7\xE3o \xE9 *{code}*.",
1062
+ securityRecommendation: "Para sua seguran\xE7a, n\xE3o o compartilhe.",
1063
+ codeExpiration: "Expira em {minutes} minutos.",
1064
+ copyCode: "Copiar c\xF3digo",
1065
+ copyOfferCode: "Copiar c\xF3digo da oferta",
1066
+ seeAllOptions: "Ver todas as op\xE7\xF5es"
1067
+ }
1068
+ };
1069
+ var localeFor = (language) => LOCALES[language.split("_")[0].toLowerCase()] ?? LOCALES.en;
1070
+
1071
+ // src/preview/model.ts
1072
+ var MAX_VISIBLE_BUTTONS = 3;
1073
+ var SAMPLE_CODE = "123456";
1074
+ function toPreviewModel(template, { values = {}, deviceLanguage = "en" } = {}) {
1075
+ const locale = localeFor(template.language);
1076
+ const { seeAllOptions } = localeFor(deviceLanguage);
1077
+ const { issues } = validate(template);
1078
+ if (template.category === "AUTHENTICATION") {
1079
+ const body = locale.authenticationBody.replace("{code}", SAMPLE_CODE);
1080
+ const security = template.body?.addSecurityRecommendation ? ` ${locale.securityRecommendation}` : "";
1081
+ const minutes = template.footer?.codeExpirationMinutes;
1082
+ return {
1083
+ body: parseText(body + security, String),
1084
+ footer: minutes === void 0 ? void 0 : parseText(
1085
+ locale.codeExpiration.replace("{minutes}", String(minutes)),
1086
+ String
1087
+ ),
1088
+ ...groupButtons(
1089
+ [{ label: template.button.text ?? locale.copyCode, icon: "copy" }],
1090
+ seeAllOptions
1091
+ ),
1092
+ issues
1093
+ };
1094
+ }
1095
+ const resolve = (name) => values[name] ?? template.variables?.[name]?.example ?? `{{${name}}}`;
1096
+ const toButtons = (buttons = []) => buttons.map((button2) => toButton(button2, locale.copyOfferCode));
1097
+ return {
1098
+ header: template.header && toHeader(template.header, resolve),
1099
+ linkPreview: template.header ? void 0 : linkPreview(template, resolve),
1100
+ limitedTimeOffer: template.limitedTimeOffer && {
1101
+ text: template.limitedTimeOffer.text,
1102
+ hasExpiration: template.limitedTimeOffer.hasExpiration ?? false
1103
+ },
1104
+ body: parseText(template.body.text, resolve),
1105
+ footer: template.footer && parseText(template.footer.text, resolve),
1106
+ ...groupButtons(toButtons(template.buttons), seeAllOptions),
1107
+ carousel: template.carousel?.cards.map(
1108
+ (card) => ({
1109
+ header: toHeader(card.header, resolve),
1110
+ body: card.body ? parseText(card.body.text, resolve) : [],
1111
+ buttons: toButtons(card.buttons)
1112
+ })
1113
+ ),
1114
+ issues
1115
+ };
1116
+ }
1117
+ function toHeader(header2, resolve) {
1118
+ switch (header2.type) {
1119
+ case "TEXT":
1120
+ return { type: "TEXT", lines: parseText(header2.text, resolve) };
1121
+ case "DOCUMENT":
1122
+ return {
1123
+ type: "DOCUMENT",
1124
+ url: header2.url,
1125
+ filename: header2.filename ?? header2.url.split("/").pop() ?? "document.pdf"
1126
+ };
1127
+ case "IMAGE":
1128
+ case "VIDEO":
1129
+ case "GIF":
1130
+ return { type: header2.type, url: header2.url };
1131
+ default:
1132
+ return { type: header2.type };
1133
+ }
1134
+ }
1135
+ function toButton(button2, copyOfferCode) {
1136
+ switch (button2.type) {
1137
+ case "URL":
1138
+ return { label: button2.text, icon: "link" };
1139
+ case "PHONE_NUMBER":
1140
+ case "VOICE_CALL":
1141
+ return { label: button2.text, icon: "phone" };
1142
+ case "COPY_CODE":
1143
+ return { label: copyOfferCode, icon: "copy" };
1144
+ case "CATALOG":
1145
+ case "MPM":
1146
+ case "SPM":
1147
+ case "ORDER_DETAILS":
1148
+ return { label: button2.text, icon: "list" };
1149
+ case "PAYMENT_REQUEST":
1150
+ return { label: button2.text ?? "", icon: "cart" };
1151
+ case "FLOW":
1152
+ return { label: button2.text, icon: "list" };
1153
+ default:
1154
+ return { label: button2.text, icon: "reply" };
1155
+ }
1156
+ }
1157
+ function linkPreview(template, resolve) {
1158
+ const button2 = template.buttons?.find(
1159
+ (b) => b.type === "URL"
1160
+ );
1161
+ if (!button2) return void 0;
1162
+ try {
1163
+ const url = new URL(replacePlaceholders(button2.url, resolve));
1164
+ return { domain: url.hostname.replace(/^www\./, "") };
1165
+ } catch {
1166
+ return void 0;
1167
+ }
1168
+ }
1169
+ function groupButtons(buttons, seeAllOptions) {
1170
+ if (buttons.length <= MAX_VISIBLE_BUTTONS) return { buttons, overflow: [] };
1171
+ return {
1172
+ buttons: [
1173
+ ...buttons.slice(0, 2),
1174
+ { label: seeAllOptions, icon: "list" }
1175
+ ],
1176
+ overflow: buttons.slice(2)
1177
+ };
1178
+ }
1179
+
1180
+ // src/preview/tree.ts
1181
+ var node = (tag, className, children = [], attrs) => ({ tag, class: className, attrs, children });
1182
+ function previewTree(model, { platform = "ios", time = "10:24" } = {}) {
1183
+ const content = node("div", "tb-bubble__content", [
1184
+ ...model.linkPreview ? [linkPreview2(model.linkPreview.domain)] : [],
1185
+ ...model.header ? [header(model.header)] : [],
1186
+ ...model.limitedTimeOffer ? [node("div", "tb-offer", [model.limitedTimeOffer.text])] : [],
1187
+ node("div", "tb-bubble__body", lines(model.body)),
1188
+ // The time sits on the footer row, or on its own row when there is no footer.
1189
+ node("div", "tb-bubble__meta", [
1190
+ node("div", "tb-bubble__footer", model.footer ? lines(model.footer) : []),
1191
+ node("time", "tb-bubble__time", [time])
1192
+ ])
1193
+ ]);
1194
+ return node(
1195
+ "div",
1196
+ "tb-preview",
1197
+ [
1198
+ node("div", "tb-bubble", [
1199
+ content,
1200
+ ...model.buttons.map(button),
1201
+ node(
1202
+ "svg",
1203
+ "tb-bubble__tail",
1204
+ [node("path", void 0, [], { d: TAIL })],
1205
+ {
1206
+ viewBox: "0 0 12.4 10.33",
1207
+ "aria-hidden": "true"
1208
+ }
1209
+ )
1210
+ ]),
1211
+ ...(model.carousel ?? []).map(
1212
+ (card) => node("div", "tb-card", [
1213
+ header(card.header),
1214
+ node("div", "tb-bubble__body", lines(card.body)),
1215
+ ...card.buttons.map(button)
1216
+ ])
1217
+ )
1218
+ ],
1219
+ { "data-platform": platform }
1220
+ );
1221
+ }
1222
+ var linkPreview2 = (domain) => node("div", "tb-link", [
1223
+ node(
1224
+ "svg",
1225
+ "tb-link__icon",
1226
+ [node("path", void 0, [], { d: LINK_ICON })],
1227
+ {
1228
+ viewBox: "0 0 9 9.33",
1229
+ "aria-hidden": "true"
1230
+ }
1231
+ ),
1232
+ node("span", void 0, [domain])
1233
+ ]);
1234
+ function header(h) {
1235
+ switch (h.type) {
1236
+ case "TEXT":
1237
+ return node("div", "tb-header tb-header--text", lines(h.lines));
1238
+ case "IMAGE":
1239
+ case "GIF":
1240
+ return node("img", "tb-header tb-header--media", [], {
1241
+ src: h.url,
1242
+ alt: ""
1243
+ });
1244
+ case "VIDEO":
1245
+ return node("video", "tb-header tb-header--media", [], {
1246
+ src: h.url,
1247
+ muted: ""
1248
+ });
1249
+ case "DOCUMENT":
1250
+ return node("div", "tb-header tb-header--document", [h.filename]);
1251
+ default:
1252
+ return node("div", `tb-header tb-header--${h.type.toLowerCase()}`);
1253
+ }
1254
+ }
1255
+ var lines = (list) => list.map(
1256
+ (line) => line.kind === "code" ? node("pre", "tb-line tb-line--code", [line.text]) : node(
1257
+ "div",
1258
+ `tb-line tb-line--${line.kind}`,
1259
+ line.spans.length ? line.spans.map(span) : ["\xA0"],
1260
+ line.kind === "numbered" ? { "data-marker": line.marker } : void 0
1261
+ )
1262
+ );
1263
+ function span(s) {
1264
+ const classes = [
1265
+ s.bold && "tb-bold",
1266
+ s.italic && "tb-italic",
1267
+ s.strike && "tb-strike",
1268
+ s.mono && "tb-mono",
1269
+ s.code && "tb-code"
1270
+ ].filter(Boolean);
1271
+ if (!classes.length && !s.variable) return s.text;
1272
+ return node(
1273
+ "span",
1274
+ classes.join(" ") || void 0,
1275
+ [s.text],
1276
+ s.variable ? { "data-variable": s.variable } : void 0
1277
+ );
1278
+ }
1279
+ var button = (b) => node("div", "tb-button", [icon(b.icon), node("span", void 0, [b.label])]);
1280
+ var icon = (name) => {
1281
+ const { width, height, paths } = ICONS[name];
1282
+ return node(
1283
+ "svg",
1284
+ "tb-button__icon",
1285
+ paths.map((d) => node("path", void 0, [], { d })),
1286
+ {
1287
+ viewBox: `0 0 ${width} ${height}`,
1288
+ width: String(width),
1289
+ height: String(height),
1290
+ "aria-hidden": "true"
1291
+ }
1292
+ );
1293
+ };
1294
+ var TAIL = "M6.2 0C6.2 4.83 3.6 8.63 0 10.33H2Q6.7 9.83 9.8 7.33L12.2 0Z";
1295
+ var LINK_ICON = "M3.9 5.4a2 2 0 0 0 2.9.1l1.4-1.4a2 2 0 0 0-2.9-2.9l-.8.8M5.1 3.9a2 2 0 0 0-2.9-.1L.8 5.2a2 2 0 0 0 2.9 2.9l.8-.8";
1296
+ var ICONS = {
1297
+ reply: {
1298
+ width: 17.67,
1299
+ height: 15,
1300
+ paths: [
1301
+ "M7.2 .9.9 6.6l6.3 5.5V8.9c4.4-.2 7.7 1.5 9.8 5.3-.4-5.4-3.7-9.6-9.8-9.9Z"
1302
+ ]
1303
+ },
1304
+ link: {
1305
+ width: 15,
1306
+ height: 15,
1307
+ paths: [
1308
+ "M3.6.6h7.8a3 3 0 0 1 3 3v7.8a3 3 0 0 1-3 3H3.6a3 3 0 0 1-3-3V3.6a3 3 0 0 1 3-3Z",
1309
+ "M5 10l5.2-5.2M6 4.8h4.2V9"
1310
+ ]
1311
+ },
1312
+ phone: {
1313
+ width: 15.67,
1314
+ height: 15.67,
1315
+ paths: [
1316
+ "M3.4.9C2.2 1.4 1 2.6.9 3.9c-.2 4.5 6.5 11.3 11.2 11.2 1.3-.1 2.5-1.3 3-2.5.2-.5 0-1-.4-1.3l-2.4-1.7c-.4-.3-1-.2-1.3.1l-1.1 1.2C8 9.9 6.1 8 5.1 6.1L6.3 5c.3-.3.4-.9.1-1.3L4.7 1.3c-.3-.4-.8-.6-1.3-.4Z"
1317
+ ]
1318
+ },
1319
+ copy: {
1320
+ width: 16.67,
1321
+ height: 20.33,
1322
+ paths: [
1323
+ "M1.6 4.8h6.6l3.9 3.9v10.1a1 1 0 0 1-1 1H1.6a1 1 0 0 1-1-1V5.8a1 1 0 0 1 1-1Z",
1324
+ "M8.2 4.8v3.9h3.9",
1325
+ "M4.6 4.8V1.6a1 1 0 0 1 1-1h6.6l3.9 3.9v10.1a1 1 0 0 1-1 1h-3",
1326
+ "M12.2.6v3.9h3.9"
1327
+ ]
1328
+ },
1329
+ list: {
1330
+ width: 15,
1331
+ height: 15,
1332
+ paths: [
1333
+ "M4.5 2.5h10M4.5 7.5h10M4.5 12.5h10M.6 2.5h.1M.6 7.5h.1M.6 12.5h.1"
1334
+ ]
1335
+ },
1336
+ cart: {
1337
+ width: 16,
1338
+ height: 15,
1339
+ paths: ["M.6 1h2.3l1.9 9.3h8.6L15.4 3.8H3.8", "M6 14h.1M13 14h.1"]
1340
+ }
1341
+ };
1342
+
1343
+ // src/editor/model.ts
1344
+ var LANGUAGES = [
1345
+ { value: "en_US", label: "English (US)" },
1346
+ { value: "pt_BR", label: "Portuguese (BR)" },
1347
+ { value: "es", label: "Spanish" }
1348
+ ];
1349
+ var CATEGORIES2 = [
1350
+ { value: "UTILITY", label: "Utility" },
1351
+ { value: "MARKETING", label: "Marketing" },
1352
+ { value: "AUTHENTICATION", label: "Authentication" }
1353
+ ];
1354
+ var HEADER_KINDS = [
1355
+ { value: "NONE", label: "None" },
1356
+ { value: "TEXT", label: "Text" },
1357
+ { value: "IMAGE", label: "Image" },
1358
+ { value: "VIDEO", label: "Video" },
1359
+ { value: "DOCUMENT", label: "Document" }
1360
+ ];
1361
+ var BUTTON_TYPES = [
1362
+ { value: "QUICK_REPLY", label: "Quick reply" },
1363
+ { value: "URL", label: "Link" },
1364
+ { value: "PHONE_NUMBER", label: "Phone" },
1365
+ { value: "COPY_CODE", label: "Copy code" }
1366
+ ];
1367
+ var MAX_BUTTONS = 10;
1368
+ var count = (text, max) => `${[...text].length}/${max}`;
1369
+ function editorSections(template, options = {}) {
1370
+ const basics = {
1371
+ id: "basics",
1372
+ title: "Basics",
1373
+ rows: [
1374
+ {
1375
+ fields: [
1376
+ {
1377
+ kind: "text",
1378
+ id: "name",
1379
+ label: "Name",
1380
+ hint: "lowercase, numbers and _",
1381
+ value: template.name,
1382
+ mono: true
1383
+ }
1384
+ ]
1385
+ },
1386
+ {
1387
+ fields: [
1388
+ {
1389
+ kind: "select",
1390
+ id: "language",
1391
+ label: "Language",
1392
+ value: template.language,
1393
+ options: options.languages ?? LANGUAGES
1394
+ },
1395
+ {
1396
+ kind: "segment",
1397
+ id: "category",
1398
+ label: "Category",
1399
+ value: template.category,
1400
+ options: CATEGORIES2
1401
+ }
1402
+ ]
1403
+ }
1404
+ ]
1405
+ };
1406
+ if (template.category === "AUTHENTICATION")
1407
+ return [basics, codeSection(template)];
1408
+ return [
1409
+ basics,
1410
+ contentSection(template),
1411
+ buttonsSection(template),
1412
+ variablesSection(template)
1413
+ ];
1414
+ }
1415
+ function contentSection(template) {
1416
+ const header2 = template.header;
1417
+ const kind = HEADER_KINDS.some((option) => option.value === header2?.type) ? header2.type : "NONE";
1418
+ const headerRow = [
1419
+ {
1420
+ kind: "select",
1421
+ id: "header.kind",
1422
+ label: "Header",
1423
+ value: kind,
1424
+ options: HEADER_KINDS
1425
+ }
1426
+ ];
1427
+ if (header2?.type === "TEXT")
1428
+ headerRow.push({
1429
+ kind: "text",
1430
+ id: "header.text",
1431
+ label: "Header text",
1432
+ hint: count(header2.text, 60),
1433
+ value: header2.text
1434
+ });
1435
+ if (header2 && "url" in header2)
1436
+ headerRow.push({
1437
+ kind: "text",
1438
+ id: "header.url",
1439
+ label: "Sample file URL",
1440
+ value: header2.url,
1441
+ mono: true
1442
+ });
1443
+ return {
1444
+ id: "content",
1445
+ title: "Content",
1446
+ rows: [
1447
+ { fields: headerRow },
1448
+ {
1449
+ fields: [
1450
+ {
1451
+ kind: "textarea",
1452
+ id: "body",
1453
+ label: "Body",
1454
+ hint: count(template.body.text, 1024),
1455
+ value: template.body.text
1456
+ }
1457
+ ]
1458
+ },
1459
+ {
1460
+ fields: [
1461
+ {
1462
+ kind: "text",
1463
+ id: "footer",
1464
+ label: "Footer",
1465
+ hint: count(template.footer?.text ?? "", 60),
1466
+ value: template.footer?.text ?? ""
1467
+ }
1468
+ ]
1469
+ }
1470
+ ],
1471
+ note: "Use {{name}} for variables and *bold*, _italic_, ~strike~ for formatting."
1472
+ };
1473
+ }
1474
+ function buttonsSection(template) {
1475
+ const buttons = template.buttons ?? [];
1476
+ const rows = buttons.map((button2, index) => {
1477
+ const at = `buttons.${index}`;
1478
+ const type = BUTTON_TYPES.some((option) => option.value === button2.type) ? button2.type : "QUICK_REPLY";
1479
+ const fields = [
1480
+ {
1481
+ kind: "select",
1482
+ id: `${at}.type`,
1483
+ label: "Type",
1484
+ value: type,
1485
+ options: BUTTON_TYPES
1486
+ }
1487
+ ];
1488
+ if ("text" in button2 && button2.text !== void 0)
1489
+ fields.push({
1490
+ kind: "text",
1491
+ id: `${at}.text`,
1492
+ label: "Button text",
1493
+ hint: count(button2.text, 25),
1494
+ value: button2.text
1495
+ });
1496
+ if (button2.type === "URL")
1497
+ fields.push({
1498
+ kind: "text",
1499
+ id: `${at}.url`,
1500
+ label: "URL",
1501
+ value: button2.url,
1502
+ mono: true
1503
+ });
1504
+ if (button2.type === "PHONE_NUMBER")
1505
+ fields.push({
1506
+ kind: "text",
1507
+ id: `${at}.phoneNumber`,
1508
+ label: "Phone number",
1509
+ value: button2.phoneNumber,
1510
+ mono: true
1511
+ });
1512
+ if (button2.type === "COPY_CODE")
1513
+ fields.push({
1514
+ kind: "text",
1515
+ id: `${at}.example`,
1516
+ label: "Sample code",
1517
+ value: button2.example,
1518
+ mono: true
1519
+ });
1520
+ fields.push({
1521
+ kind: "action",
1522
+ id: `${at}.remove`,
1523
+ label: "Remove button",
1524
+ icon: "remove"
1525
+ });
1526
+ return { fields, list: true };
1527
+ });
1528
+ rows.push({
1529
+ fields: [
1530
+ {
1531
+ kind: "action",
1532
+ id: "buttons.add",
1533
+ label: "Add button",
1534
+ icon: "add",
1535
+ disabled: buttons.length >= MAX_BUTTONS
1536
+ }
1537
+ ]
1538
+ });
1539
+ return {
1540
+ id: "buttons",
1541
+ title: `Buttons ${buttons.length}/${MAX_BUTTONS}`,
1542
+ rows
1543
+ };
1544
+ }
1545
+ function variablesSection(template) {
1546
+ const variables = Object.entries(template.variables ?? {});
1547
+ return {
1548
+ id: "variables",
1549
+ title: "Variables",
1550
+ rows: variables.map(([name, variable], index) => ({
1551
+ fields: [
1552
+ {
1553
+ kind: "text",
1554
+ id: `variables.${name}`,
1555
+ label: `{{${name}}}`,
1556
+ hint: `position ${index + 1}`,
1557
+ value: variable.example,
1558
+ placeholder: "Example value"
1559
+ }
1560
+ ]
1561
+ })),
1562
+ note: variables.length ? void 0 : "Type a placeholder such as {{first_name}} in the text to add one."
1563
+ };
1564
+ }
1565
+ function codeSection(template) {
1566
+ const minutes = template.footer?.codeExpirationMinutes;
1567
+ return {
1568
+ id: "code",
1569
+ title: "Code message",
1570
+ rows: [
1571
+ {
1572
+ fields: [
1573
+ {
1574
+ kind: "checkbox",
1575
+ id: "body.addSecurityRecommendation",
1576
+ label: "Add \u201Cdo not share this code\u201D",
1577
+ checked: template.body?.addSecurityRecommendation ?? false
1578
+ }
1579
+ ]
1580
+ },
1581
+ {
1582
+ fields: [
1583
+ {
1584
+ kind: "text",
1585
+ id: "footer.codeExpirationMinutes",
1586
+ label: "Expires after",
1587
+ hint: "minutes, 1 to 90",
1588
+ value: minutes === void 0 ? "" : String(minutes),
1589
+ mono: true
1590
+ },
1591
+ {
1592
+ kind: "text",
1593
+ id: "button.text",
1594
+ label: "Button text",
1595
+ hint: "optional",
1596
+ value: template.button.text ?? "",
1597
+ placeholder: "Copy code"
1598
+ }
1599
+ ]
1600
+ }
1601
+ ]
1602
+ };
1603
+ }
1604
+ function applyEdit(template, id, value, remembered = {}) {
1605
+ const text = typeof value === "string" ? value : "";
1606
+ if (id === "name") return { ...template, name: text };
1607
+ if (id === "language") return { ...template, language: text };
1608
+ if (id === "category") return changeCategory(template, text);
1609
+ if (template.category === "AUTHENTICATION") {
1610
+ if (id === "body.addSecurityRecommendation")
1611
+ return {
1612
+ ...template,
1613
+ body: { addSecurityRecommendation: value === true }
1614
+ };
1615
+ if (id === "footer.codeExpirationMinutes") {
1616
+ const digits = text.replace(/\D/g, "");
1617
+ return {
1618
+ ...template,
1619
+ footer: digits ? { codeExpirationMinutes: Number(digits) } : void 0
1620
+ };
1621
+ }
1622
+ if (id === "button.text")
1623
+ return {
1624
+ ...template,
1625
+ button: { ...template.button, text: text || void 0 }
1626
+ };
1627
+ return template;
1628
+ }
1629
+ if (id.startsWith("variables."))
1630
+ return {
1631
+ ...template,
1632
+ variables: {
1633
+ ...template.variables,
1634
+ [id.slice("variables.".length)]: { example: text }
1635
+ }
1636
+ };
1637
+ return syncVariables(editStandard(template, id, text), remembered);
1638
+ }
1639
+ function editStandard(template, id, text) {
1640
+ if (id === "header.kind") return { ...template, header: newHeader(text) };
1641
+ if (id === "header.text")
1642
+ return { ...template, header: { type: "TEXT", text } };
1643
+ if (id === "header.url" && template.header && "url" in template.header)
1644
+ return { ...template, header: { ...template.header, url: text } };
1645
+ if (id === "body") return { ...template, body: { text } };
1646
+ if (id === "footer")
1647
+ return { ...template, footer: text ? { text } : void 0 };
1648
+ const buttons = template.buttons ?? [];
1649
+ const withButtons = (next2) => ({
1650
+ ...template,
1651
+ buttons: next2.length ? next2 : void 0
1652
+ });
1653
+ if (id === "buttons.add")
1654
+ return buttons.length >= MAX_BUTTONS ? template : withButtons([...buttons, newButton("QUICK_REPLY")]);
1655
+ const match = /^buttons\.(\d+)\.(\w+)$/.exec(id);
1656
+ if (!match) return template;
1657
+ const index = Number(match[1]);
1658
+ const field = match[2];
1659
+ const button2 = buttons[index];
1660
+ if (!button2) return template;
1661
+ if (field === "remove")
1662
+ return withButtons(buttons.filter((_, i) => i !== index));
1663
+ const next = field === "type" ? newButton(
1664
+ text,
1665
+ "text" in button2 ? button2.text ?? "" : ""
1666
+ ) : { ...button2, [field]: text };
1667
+ return withButtons(
1668
+ buttons.map((current, i) => i === index ? next : current)
1669
+ );
1670
+ }
1671
+ function changeCategory(template, category) {
1672
+ const { name, language } = template;
1673
+ if (category === "AUTHENTICATION")
1674
+ return {
1675
+ name,
1676
+ language,
1677
+ category,
1678
+ body: { addSecurityRecommendation: true },
1679
+ footer: { codeExpirationMinutes: 10 },
1680
+ button: { type: "OTP", otpType: "COPY_CODE" }
1681
+ };
1682
+ if (template.category === "AUTHENTICATION")
1683
+ return { name, language, category, body: { text: "" } };
1684
+ return { ...template, category };
1685
+ }
1686
+ function syncVariables(template, remembered = {}) {
1687
+ const current = template.variables ?? {};
1688
+ const variables = Object.fromEntries(
1689
+ extractVariables(template).map((name) => [
1690
+ name,
1691
+ current[name] ?? remembered[name] ?? { example: "" }
1692
+ ])
1693
+ );
1694
+ return {
1695
+ ...template,
1696
+ variables: Object.keys(variables).length ? variables : void 0
1697
+ };
1698
+ }
1699
+ function newHeader(kind) {
1700
+ if (kind === "TEXT") return { type: "TEXT", text: "" };
1701
+ if (kind === "IMAGE" || kind === "VIDEO" || kind === "DOCUMENT")
1702
+ return { type: kind, url: "https://" };
1703
+ return void 0;
1704
+ }
1705
+ function newButton(type, label = "") {
1706
+ if (type === "URL")
1707
+ return { type, text: label || "Open link", url: "https://" };
1708
+ if (type === "PHONE_NUMBER")
1709
+ return { type, text: label || "Call us", phoneNumber: "+" };
1710
+ if (type === "COPY_CODE") return { type, example: "CODE10" };
1711
+ return { type: "QUICK_REPLY", text: label || "Reply" };
1712
+ }
1713
+
1714
+ // src/editor/tree.ts
1715
+ function editorTree(template, options = {}) {
1716
+ const prefix = options.idPrefix ?? "tb";
1717
+ const sections = editorSections(template, options);
1718
+ return node2(
1719
+ "div",
1720
+ "tb-editor",
1721
+ {},
1722
+ sections.map(
1723
+ (section, index) => node2("section", "tb-editor__section", { "data-section": section.id }, [
1724
+ node2("h3", "tb-editor__title", {}, [
1725
+ node2("span", "tb-editor__index", {}, [
1726
+ String(index + 1).padStart(2, "0")
1727
+ ]),
1728
+ section.title
1729
+ ]),
1730
+ node2("div", "tb-editor__body", {}, [
1731
+ ...section.rows.map(
1732
+ (row) => node2(
1733
+ "div",
1734
+ row.list ? "tb-editor__row tb-editor__row--list" : "tb-editor__row",
1735
+ {},
1736
+ row.fields.map(
1737
+ (field) => fieldNode(field, `${prefix}-${field.id.replace(/\W/g, "-")}`)
1738
+ )
1739
+ )
1740
+ ),
1741
+ ...section.note ? [node2("p", "tb-editor__note", {}, [section.note])] : []
1742
+ ])
1743
+ ])
1744
+ )
1745
+ );
1746
+ }
1747
+ function fieldNode(field, id) {
1748
+ const edit = { "data-edit": field.id };
1749
+ if (field.kind === "action")
1750
+ return node2(
1751
+ "button",
1752
+ `tb-editor__action tb-editor__action--${field.icon}`,
1753
+ {
1754
+ ...edit,
1755
+ type: "button",
1756
+ "aria-label": field.label,
1757
+ ...field.disabled && { disabled: "" }
1758
+ },
1759
+ [field.icon === "remove" ? "\xD7" : `+ ${field.label}`]
1760
+ );
1761
+ if (field.kind === "checkbox")
1762
+ return node2("label", "tb-editor__check", {}, [
1763
+ node2("input", void 0, {
1764
+ ...edit,
1765
+ id,
1766
+ type: "checkbox",
1767
+ ...field.checked && { checked: "" }
1768
+ }),
1769
+ field.label
1770
+ ]);
1771
+ const head = node2("div", "tb-editor__head", {}, [
1772
+ field.kind === "segment" ? node2("span", "tb-editor__label", { id: `${id}-label` }, [field.label]) : node2("label", "tb-editor__label", { for: id }, [field.label]),
1773
+ ...field.hint ? [node2("span", "tb-editor__hint", {}, [field.hint])] : []
1774
+ ]);
1775
+ let control;
1776
+ if (field.kind === "segment")
1777
+ control = node2(
1778
+ "div",
1779
+ "tb-editor__segment",
1780
+ { role: "radiogroup", "aria-labelledby": `${id}-label` },
1781
+ field.options.map((option) => {
1782
+ const selected = option.value === field.value;
1783
+ return node2(
1784
+ "button",
1785
+ "tb-editor__segment-item",
1786
+ {
1787
+ ...edit,
1788
+ "data-value": option.value,
1789
+ type: "button",
1790
+ role: "radio",
1791
+ "aria-checked": String(selected),
1792
+ tabindex: selected ? "0" : "-1"
1793
+ },
1794
+ [option.label]
1795
+ );
1796
+ })
1797
+ );
1798
+ else if (field.kind === "select")
1799
+ control = node2(
1800
+ "select",
1801
+ "tb-editor__input tb-editor__input--select",
1802
+ // The selected option carries the value, as it does in plain HTML.
1803
+ { ...edit, id },
1804
+ field.options.map(
1805
+ (option) => node2(
1806
+ "option",
1807
+ void 0,
1808
+ {
1809
+ value: option.value,
1810
+ ...option.value === field.value && { selected: "" }
1811
+ },
1812
+ [option.label]
1813
+ )
1814
+ )
1815
+ );
1816
+ else
1817
+ control = node2(
1818
+ field.kind === "textarea" ? "textarea" : "input",
1819
+ [
1820
+ "tb-editor__input",
1821
+ field.mono && "tb-editor__input--mono",
1822
+ field.kind === "textarea" && "tb-editor__input--area"
1823
+ ].filter(Boolean).join(" "),
1824
+ {
1825
+ ...edit,
1826
+ id,
1827
+ value: field.value,
1828
+ ...field.kind === "text" && { type: "text" },
1829
+ ...field.kind === "textarea" && { rows: "7" },
1830
+ ...field.placeholder && { placeholder: field.placeholder }
1831
+ }
1832
+ );
1833
+ return node2("div", "tb-editor__field", {}, [head, control]);
1834
+ }
1835
+ function node2(tag, className, attrs, children = []) {
1836
+ return { tag, ...className && { class: className }, attrs, children };
1837
+ }
1838
+ // Annotate the CommonJS export names for ESM import in node:
1839
+ 0 && (module.exports = {
1840
+ TemplateValidationError,
1841
+ UnsupportedByProviderError,
1842
+ applyEdit,
1843
+ changeCategory,
1844
+ codes,
1845
+ defineAdapter,
1846
+ editorSections,
1847
+ editorTree,
1848
+ extractVariables,
1849
+ previewTree,
1850
+ syncVariables,
1851
+ toCurl,
1852
+ toPreviewModel,
1853
+ toRequests,
1854
+ validate
1855
+ });