@samva/markup 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.
Files changed (124) hide show
  1. package/AGENTS.md +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +41 -0
  4. package/dist/ast-DAhnAvQN.d.ts +120 -0
  5. package/dist/ast.d.ts +2 -0
  6. package/dist/ast.js +159 -0
  7. package/dist/ast.js.map +1 -0
  8. package/dist/candidate.d.ts +29 -0
  9. package/dist/candidate.js +88 -0
  10. package/dist/candidate.js.map +1 -0
  11. package/dist/checks-CW0nFj5n.js +58922 -0
  12. package/dist/checks-CW0nFj5n.js.map +1 -0
  13. package/dist/checks.d.ts +26 -0
  14. package/dist/checks.js +2 -0
  15. package/dist/classes.d.ts +27 -0
  16. package/dist/classes.js +184 -0
  17. package/dist/classes.js.map +1 -0
  18. package/dist/compile-sms.d.ts +28 -0
  19. package/dist/compile-sms.js +44 -0
  20. package/dist/compile-sms.js.map +1 -0
  21. package/dist/compile-whatsapp.d.ts +56 -0
  22. package/dist/compile-whatsapp.js +186 -0
  23. package/dist/compile-whatsapp.js.map +1 -0
  24. package/dist/compile.d.ts +93 -0
  25. package/dist/compile.js +938 -0
  26. package/dist/compile.js.map +1 -0
  27. package/dist/components.d.ts +289 -0
  28. package/dist/components.js +106 -0
  29. package/dist/components.js.map +1 -0
  30. package/dist/conditionals.d.ts +10 -0
  31. package/dist/conditionals.js +42 -0
  32. package/dist/conditionals.js.map +1 -0
  33. package/dist/diagnostics-oveE-laV.d.ts +79 -0
  34. package/dist/diagnostics.d.ts +2 -0
  35. package/dist/diagnostics.js +62 -0
  36. package/dist/diagnostics.js.map +1 -0
  37. package/dist/emit.d.ts +50 -0
  38. package/dist/emit.js +151 -0
  39. package/dist/emit.js.map +1 -0
  40. package/dist/footer.d.ts +66 -0
  41. package/dist/footer.js +90 -0
  42. package/dist/footer.js.map +1 -0
  43. package/dist/interpolate.d.ts +27 -0
  44. package/dist/interpolate.js +61 -0
  45. package/dist/interpolate.js.map +1 -0
  46. package/dist/jsx-dev-runtime.d.ts +10 -0
  47. package/dist/jsx-dev-runtime.js +7 -0
  48. package/dist/jsx-dev-runtime.js.map +1 -0
  49. package/dist/jsx-runtime-fSxcvlx4.d.ts +57 -0
  50. package/dist/jsx-runtime.d.ts +2 -0
  51. package/dist/jsx-runtime.js +102 -0
  52. package/dist/jsx-runtime.js.map +1 -0
  53. package/dist/lexer.d.ts +60 -0
  54. package/dist/lexer.js +269 -0
  55. package/dist/lexer.js.map +1 -0
  56. package/dist/model.d.ts +39 -0
  57. package/dist/model.js +90 -0
  58. package/dist/model.js.map +1 -0
  59. package/dist/ops.d.ts +208 -0
  60. package/dist/ops.js +546 -0
  61. package/dist/ops.js.map +1 -0
  62. package/dist/parser-yzjUOGhc.d.ts +387 -0
  63. package/dist/parser.d.ts +2 -0
  64. package/dist/parser.js +821 -0
  65. package/dist/parser.js.map +1 -0
  66. package/dist/predicate-DTtVtLK7.d.ts +49 -0
  67. package/dist/predicate.d.ts +2 -0
  68. package/dist/predicate.js +417 -0
  69. package/dist/predicate.js.map +1 -0
  70. package/dist/presets/category-mapping.d.ts +13 -0
  71. package/dist/presets/category-mapping.js +49 -0
  72. package/dist/presets/category-mapping.js.map +1 -0
  73. package/dist/presets/public-gallery.d.ts +490 -0
  74. package/dist/presets/public-gallery.js +563 -0
  75. package/dist/presets/public-gallery.js.map +1 -0
  76. package/dist/presets/public-handoff.d.ts +68 -0
  77. package/dist/presets/public-handoff.js +105 -0
  78. package/dist/presets/public-handoff.js.map +1 -0
  79. package/dist/presets/public-preview-artifacts.d.ts +41 -0
  80. package/dist/presets/public-preview-artifacts.js +109 -0
  81. package/dist/presets/public-preview-artifacts.js.map +1 -0
  82. package/dist/presets/public-preview.d.ts +1168 -0
  83. package/dist/presets/public-preview.js +1158 -0
  84. package/dist/presets/public-preview.js.map +1 -0
  85. package/dist/presets/sources.d.ts +7 -0
  86. package/dist/presets/sources.js +54 -0
  87. package/dist/presets/sources.js.map +1 -0
  88. package/dist/presets.d.ts +350 -0
  89. package/dist/presets.js +1251 -0
  90. package/dist/presets.js.map +1 -0
  91. package/dist/printer.d.ts +6 -0
  92. package/dist/printer.js +122 -0
  93. package/dist/printer.js.map +1 -0
  94. package/dist/registry.d.ts +42 -0
  95. package/dist/registry.js +646 -0
  96. package/dist/registry.js.map +1 -0
  97. package/dist/render-template.d.ts +42 -0
  98. package/dist/render-template.js +74 -0
  99. package/dist/render-template.js.map +1 -0
  100. package/dist/sms-segments-y4FpPilJ.d.ts +13 -0
  101. package/dist/sms-segments.d.ts +2 -0
  102. package/dist/sms-segments.js +46 -0
  103. package/dist/sms-segments.js.map +1 -0
  104. package/dist/theme-6ApjjiBo.d.ts +116 -0
  105. package/dist/theme-css.d.ts +24 -0
  106. package/dist/theme-css.js +509 -0
  107. package/dist/theme-css.js.map +1 -0
  108. package/dist/theme.d.ts +2 -0
  109. package/dist/theme.js +95 -0
  110. package/dist/theme.js.map +1 -0
  111. package/dist/tokens.d.ts +6 -0
  112. package/dist/tokens.js +417 -0
  113. package/dist/tokens.js.map +1 -0
  114. package/dist/variables-3NGLpNTB.d.ts +77 -0
  115. package/dist/variables.d.ts +2 -0
  116. package/dist/variables.js +141 -0
  117. package/dist/variables.js.map +1 -0
  118. package/dist/version-BITr58kk.js +96 -0
  119. package/dist/version-BITr58kk.js.map +1 -0
  120. package/generated/sml-agent-contract.json +917 -0
  121. package/generated/sml-email-agent-contract.json +790 -0
  122. package/generated/sml-email-language-reference.md +160 -0
  123. package/generated/sml-language-reference.md +220 -0
  124. package/package.json +203 -0
package/dist/parser.js ADDED
@@ -0,0 +1,821 @@
1
+ import { SmlParseError } from "./diagnostics.js";
2
+ import { parsePredicate, printPredicate } from "./predicate.js";
3
+ import { CHANNEL_BY_ROOT_TAG, ROOT_TAGS, SML_TAGS, isRootTag } from "./ast.js";
4
+ import { TokenKind, createLexer } from "./lexer.js";
5
+ import { Result } from "effect";
6
+ //#region src/parser.ts
7
+ const SMS_CATEGORIES = [
8
+ "transactional",
9
+ "promotional",
10
+ "service_implicit"
11
+ ];
12
+ const WHATSAPP_CATEGORIES = [
13
+ "marketing",
14
+ "utility",
15
+ "authentication",
16
+ "service"
17
+ ];
18
+ const WHATSAPP_HEADER_TYPES = [
19
+ "text",
20
+ "image",
21
+ "video",
22
+ "document"
23
+ ];
24
+ const WHATSAPP_BUTTON_TYPES = [
25
+ "quick-reply",
26
+ "url",
27
+ "phone",
28
+ "copy-code"
29
+ ];
30
+ /**
31
+ * Background-image attributes on the email block containers (Section/Container).
32
+ * A URL is an ATTRIBUTE, not a class token: a URL inside the closed class
33
+ * grammar is hostile (escaping, cache eviction, no caniemail slug), so it rides
34
+ * the attribute channel like `src`/`href`. The compiler emits email-safe
35
+ * background CSS from these (escaped `url(...)` + an Outlook `background` cell
36
+ * attribute); Outlook for Windows renders no CSS background image, so
37
+ * `checks` warns via `css-background-image`. Position/size are ignored by the
38
+ * compiler when no `backgroundImage` is set.
39
+ */
40
+ const BACKGROUND_ATTRIBUTES = {
41
+ backgroundImage: { type: "string" },
42
+ backgroundPosition: {
43
+ type: "string",
44
+ values: [
45
+ "center",
46
+ "top",
47
+ "bottom"
48
+ ]
49
+ },
50
+ backgroundSize: {
51
+ type: "string",
52
+ values: [
53
+ "cover",
54
+ "contain",
55
+ "auto"
56
+ ]
57
+ }
58
+ };
59
+ /**
60
+ * The email language surface as data: per-tag attributes
61
+ * (type/required/bounds) and content model. Public API — typed TSX components
62
+ * and the reference-docs generator derive from this table, so it cannot drift
63
+ * from the parser.
64
+ */
65
+ const EMAIL_TAG_SPECS = {
66
+ Email: {
67
+ attributes: {
68
+ lang: { type: "string" },
69
+ dir: { type: "string" },
70
+ width: {
71
+ type: "number",
72
+ min: 1,
73
+ max: 1e4
74
+ },
75
+ class: { type: "string" }
76
+ },
77
+ content: "email"
78
+ },
79
+ Subject: {
80
+ attributes: {},
81
+ content: "preview"
82
+ },
83
+ Preview: {
84
+ attributes: {},
85
+ content: "preview"
86
+ },
87
+ Container: {
88
+ attributes: {
89
+ class: { type: "string" },
90
+ ...BACKGROUND_ATTRIBUTES
91
+ },
92
+ content: "block"
93
+ },
94
+ Section: {
95
+ attributes: {
96
+ class: { type: "string" },
97
+ ...BACKGROUND_ATTRIBUTES
98
+ },
99
+ content: "block"
100
+ },
101
+ Row: {
102
+ attributes: { class: { type: "string" } },
103
+ content: "columns"
104
+ },
105
+ Column: {
106
+ attributes: {
107
+ class: { type: "string" },
108
+ width: { type: "string" },
109
+ valign: {
110
+ type: "string",
111
+ values: [
112
+ "top",
113
+ "middle",
114
+ "bottom"
115
+ ]
116
+ }
117
+ },
118
+ content: "block"
119
+ },
120
+ Heading: {
121
+ attributes: {
122
+ level: {
123
+ type: "number",
124
+ min: 1,
125
+ max: 6
126
+ },
127
+ class: { type: "string" }
128
+ },
129
+ content: "inline"
130
+ },
131
+ Text: {
132
+ attributes: { class: { type: "string" } },
133
+ content: "inline"
134
+ },
135
+ Button: {
136
+ attributes: {
137
+ href: {
138
+ type: "string",
139
+ required: true
140
+ },
141
+ class: { type: "string" }
142
+ },
143
+ content: "inline-nolink"
144
+ },
145
+ Image: {
146
+ attributes: {
147
+ src: {
148
+ type: "string",
149
+ required: true
150
+ },
151
+ alt: { type: "string" },
152
+ width: {
153
+ type: "number",
154
+ min: 0,
155
+ max: 1e4
156
+ },
157
+ height: {
158
+ type: "number",
159
+ min: 0,
160
+ max: 1e4
161
+ },
162
+ class: { type: "string" }
163
+ },
164
+ content: "image"
165
+ },
166
+ Source: {
167
+ attributes: {
168
+ scheme: {
169
+ type: "string",
170
+ required: true,
171
+ values: ["dark"]
172
+ },
173
+ src: {
174
+ type: "string",
175
+ required: true
176
+ }
177
+ },
178
+ content: "void"
179
+ },
180
+ Divider: {
181
+ attributes: { class: { type: "string" } },
182
+ content: "void"
183
+ },
184
+ Spacer: {
185
+ attributes: { height: {
186
+ type: "number",
187
+ required: true,
188
+ min: 1,
189
+ max: 2e3
190
+ } },
191
+ content: "void"
192
+ },
193
+ List: {
194
+ attributes: {
195
+ type: {
196
+ type: "string",
197
+ values: ["unordered", "ordered"]
198
+ },
199
+ class: { type: "string" }
200
+ },
201
+ content: "list"
202
+ },
203
+ ListItem: {
204
+ attributes: { class: { type: "string" } },
205
+ content: "inline"
206
+ },
207
+ If: {
208
+ attributes: { test: {
209
+ type: "string",
210
+ required: true
211
+ } },
212
+ content: "if"
213
+ },
214
+ Else: {
215
+ attributes: {},
216
+ content: "block"
217
+ },
218
+ a: {
219
+ attributes: {
220
+ href: {
221
+ type: "string",
222
+ required: true
223
+ },
224
+ class: { type: "string" }
225
+ },
226
+ content: "inline-nolink"
227
+ },
228
+ strong: {
229
+ attributes: {},
230
+ content: "inline-nolink"
231
+ },
232
+ em: {
233
+ attributes: {},
234
+ content: "inline-nolink"
235
+ }
236
+ };
237
+ /**
238
+ * The sms language surface: a raw verbatim text body with `{{vars}}` and
239
+ * send-time conditionals — no layout tags, no classes, no theming.
240
+ */
241
+ const SMS_TAG_SPECS = {
242
+ Sms: {
243
+ attributes: { category: {
244
+ type: "string",
245
+ values: SMS_CATEGORIES
246
+ } },
247
+ content: "verbatim"
248
+ },
249
+ If: {
250
+ attributes: { test: {
251
+ type: "string",
252
+ required: true
253
+ } },
254
+ content: "verbatim-if"
255
+ },
256
+ Else: {
257
+ attributes: {},
258
+ content: "verbatim"
259
+ }
260
+ };
261
+ /**
262
+ * The whatsapp language surface: Meta's template anatomy IS the closed
263
+ * grammar — Header? Body Footer? Buttons?, verbatim text slots, no classes.
264
+ * Root attributes carry authoring intent (name defaults from the template
265
+ * slug, language from the variant locale — defaulting happens at the API
266
+ * layer; the language just carries the attributes).
267
+ */
268
+ const WHATSAPP_TAG_SPECS = {
269
+ WhatsApp: {
270
+ attributes: {
271
+ name: { type: "string" },
272
+ language: { type: "string" },
273
+ category: {
274
+ type: "string",
275
+ required: true,
276
+ values: WHATSAPP_CATEGORIES
277
+ }
278
+ },
279
+ content: "whatsapp"
280
+ },
281
+ Header: {
282
+ attributes: { type: {
283
+ type: "string",
284
+ values: WHATSAPP_HEADER_TYPES
285
+ } },
286
+ content: "verbatim"
287
+ },
288
+ Body: {
289
+ attributes: {},
290
+ content: "verbatim"
291
+ },
292
+ Footer: {
293
+ attributes: {},
294
+ content: "verbatim"
295
+ },
296
+ Buttons: {
297
+ attributes: {},
298
+ content: "buttons"
299
+ },
300
+ Button: {
301
+ attributes: {
302
+ type: {
303
+ type: "string",
304
+ required: true,
305
+ values: WHATSAPP_BUTTON_TYPES
306
+ },
307
+ url: { type: "string" },
308
+ phone: { type: "string" }
309
+ },
310
+ content: "verbatim"
311
+ },
312
+ If: {
313
+ attributes: { test: {
314
+ type: "string",
315
+ required: true
316
+ } },
317
+ content: "verbatim-if"
318
+ },
319
+ Else: {
320
+ attributes: {},
321
+ content: "verbatim"
322
+ }
323
+ };
324
+ /** Per-channel tag tables — the root tag picks one for the whole document. */
325
+ const CHANNEL_TAG_SPECS = {
326
+ email: EMAIL_TAG_SPECS,
327
+ sms: SMS_TAG_SPECS,
328
+ whatsapp: WHATSAPP_TAG_SPECS
329
+ };
330
+ /**
331
+ * Editor-only attributes accepted on every email block-level tag on top of the
332
+ * tag's own spec. The compiler ignores both, so neither ever reaches HTML:
333
+ * `label` renames a node in the layout navigator; `hidden="true"` drops it from
334
+ * the compiled artifact while it stays in the source. Email-only — sms/whatsapp
335
+ * compile has no strip pass, so their tags keep their own specs verbatim.
336
+ */
337
+ const UNIVERSAL_ATTRIBUTES = {
338
+ label: { type: "string" },
339
+ hidden: {
340
+ type: "string",
341
+ values: ["true"]
342
+ }
343
+ };
344
+ /** Email tags a user manipulates as layers — the ones the universal attributes attach to. */
345
+ const UNIVERSAL_ATTRIBUTE_TAGS = /* @__PURE__ */ new Set([
346
+ "Container",
347
+ "Section",
348
+ "Row",
349
+ "Column",
350
+ "Heading",
351
+ "Text",
352
+ "Button",
353
+ "Image",
354
+ "Divider",
355
+ "Spacer",
356
+ "List",
357
+ "ListItem",
358
+ "If",
359
+ "Else"
360
+ ]);
361
+ const universalAttributeSpec = (name) => Object.hasOwn(UNIVERSAL_ATTRIBUTES, name) ? UNIVERSAL_ATTRIBUTES[name] : void 0;
362
+ const BLOCK_CHILDREN = [
363
+ "Container",
364
+ "Section",
365
+ "Row",
366
+ "Heading",
367
+ "Text",
368
+ "Button",
369
+ "Image",
370
+ "Divider",
371
+ "Spacer",
372
+ "List",
373
+ "If"
374
+ ];
375
+ const EMAIL_ENVELOPE_CHILDREN = ["Subject", "Preview"];
376
+ /** Tags a content model admits as direct children (public alongside the spec tables). */
377
+ const allowedChildren = (model) => {
378
+ switch (model) {
379
+ case "email": return [...EMAIL_ENVELOPE_CHILDREN, ...BLOCK_CHILDREN];
380
+ case "block": return BLOCK_CHILDREN;
381
+ case "columns": return ["Column"];
382
+ case "list": return ["ListItem"];
383
+ case "image": return ["Source"];
384
+ case "if": return [...BLOCK_CHILDREN, "Else"];
385
+ case "inline": return [
386
+ "strong",
387
+ "em",
388
+ "a"
389
+ ];
390
+ case "inline-nolink": return ["strong", "em"];
391
+ case "whatsapp": return [
392
+ "Header",
393
+ "Body",
394
+ "Footer",
395
+ "Buttons"
396
+ ];
397
+ case "buttons": return ["Button"];
398
+ case "verbatim": return ["If"];
399
+ case "verbatim-if": return ["If", "Else"];
400
+ case "preview":
401
+ case "void": return [];
402
+ }
403
+ };
404
+ const isInlineModel = (model) => model === "inline" || model === "inline-nolink" || model === "preview";
405
+ const isVerbatimModel = (model) => model === "verbatim" || model === "verbatim-if";
406
+ /** Models whose direct children may be text / interpolations. */
407
+ const admitsText = (model) => isInlineModel(model) || isVerbatimModel(model);
408
+ const listTags = (tags) => tags.map((tag) => `<${tag}>`).join(", ");
409
+ /** Wrapper tag suggested when a loose child isn't allowed directly under its model. */
410
+ const WRAPPER_TAG_BY_MODEL = {
411
+ list: "ListItem",
412
+ whatsapp: "Body",
413
+ buttons: "Button"
414
+ };
415
+ const parseSml = (source) => {
416
+ const lexer = createLexer(source);
417
+ let diagnostic;
418
+ let channel = null;
419
+ let specs = EMAIL_TAG_SPECS;
420
+ const fail = (code, message, span) => {
421
+ diagnostic ??= {
422
+ code,
423
+ message,
424
+ span
425
+ };
426
+ return null;
427
+ };
428
+ const failToken = (token) => {
429
+ const error = token.error ?? {
430
+ code: "unexpected-token",
431
+ message: `unexpected ${JSON.stringify(lexer.getText(token))}`
432
+ };
433
+ return fail(error.code, error.message, {
434
+ start: token.start,
435
+ end: token.end
436
+ });
437
+ };
438
+ const parseAttributeValue = (tag, name, spec, nameToken) => {
439
+ const nameSpan = {
440
+ start: nameToken.start,
441
+ end: nameToken.end
442
+ };
443
+ if (lexer.scanTagToken().kind !== TokenKind.Equals) return fail("missing-attribute-value", `attribute "${name}" needs a value — write ${name}=${spec.type === "number" ? `{${spec.min ?? 1}}` : "\"...\""}`, nameSpan);
444
+ const valueToken = lexer.scanTagToken();
445
+ if (valueToken.kind === TokenKind.Unknown) return failToken(valueToken);
446
+ if (valueToken.kind === TokenKind.Identifier || valueToken.kind === TokenKind.Number) {
447
+ const raw = lexer.getText(valueToken);
448
+ const suggestion = spec.type === "number" ? `${name}={${raw}}` : `${name}="${raw}"`;
449
+ return fail("unquoted-attribute", `attribute values must be quoted — write ${suggestion}`, {
450
+ start: valueToken.start,
451
+ end: valueToken.end
452
+ });
453
+ }
454
+ if (valueToken.kind === TokenKind.OpenBrace) {
455
+ const numberToken = lexer.scanTagToken();
456
+ if (numberToken.kind !== TokenKind.Number) return fail("invalid-attribute-value", `expected a number inside braces — write ${name}={16}`, {
457
+ start: numberToken.start,
458
+ end: numberToken.end
459
+ });
460
+ const closeToken = lexer.scanTagToken();
461
+ if (closeToken.kind !== TokenKind.CloseBrace) return fail("invalid-attribute-value", `missing } after ${name}={${lexer.getText(numberToken)}`, {
462
+ start: closeToken.start,
463
+ end: closeToken.end
464
+ });
465
+ if (spec.type !== "number") return fail("invalid-attribute-value", `"${name}" on <${tag}> is a string attribute — write ${name}="..."`, {
466
+ start: valueToken.start,
467
+ end: closeToken.end
468
+ });
469
+ return {
470
+ value: {
471
+ kind: "number",
472
+ value: Number.parseInt(lexer.getText(numberToken), 10)
473
+ },
474
+ valueSpan: {
475
+ start: numberToken.start,
476
+ end: numberToken.end
477
+ }
478
+ };
479
+ }
480
+ if (valueToken.kind !== TokenKind.String) return fail("missing-attribute-value", `attribute "${name}" needs a value — write ${name}=${spec.type === "number" ? "{16}" : "\"...\""}`, {
481
+ start: valueToken.start,
482
+ end: valueToken.end
483
+ });
484
+ const raw = lexer.getStringValue(valueToken);
485
+ const valueSpan = {
486
+ start: valueToken.start + 1,
487
+ end: valueToken.end - 1
488
+ };
489
+ if (spec.type === "number") {
490
+ if (!/^\d+$/.test(raw)) return fail("invalid-attribute-value", `"${name}" on <${tag}> must be a number — write ${name}={16}`, valueSpan);
491
+ return {
492
+ value: {
493
+ kind: "number",
494
+ value: Number.parseInt(raw, 10)
495
+ },
496
+ valueSpan
497
+ };
498
+ }
499
+ if (spec.values !== void 0 && !spec.values.includes(raw)) return fail("invalid-attribute-value", `"${name}" on <${tag}> must be one of ${spec.values.map((value) => `"${value}"`).join(", ")}`, valueSpan);
500
+ return {
501
+ value: {
502
+ kind: "string",
503
+ value: raw
504
+ },
505
+ valueSpan
506
+ };
507
+ };
508
+ const validateNumberBounds = (tag, name, spec, attribute) => {
509
+ if (attribute.value.kind !== "number") return true;
510
+ const { value } = attribute.value;
511
+ if (spec.min !== void 0 && value < spec.min) {
512
+ fail("invalid-attribute-value", `"${name}" on <${tag}> must be at least ${spec.min}`, attribute.valueSpan);
513
+ return false;
514
+ }
515
+ if (spec.max !== void 0 && value > spec.max) {
516
+ fail("invalid-attribute-value", `"${name}" on <${tag}> must be at most ${spec.max}`, attribute.valueSpan);
517
+ return false;
518
+ }
519
+ return true;
520
+ };
521
+ /** Called with the `<` already consumed; `lessThanToken` is that token. */
522
+ const parseElement = (lessThanToken) => {
523
+ const tagToken = lexer.scanTagToken();
524
+ if (tagToken.kind !== TokenKind.Identifier) {
525
+ if (tagToken.kind === TokenKind.Unknown) return failToken(tagToken);
526
+ return fail("unknown-tag", "expected a tag name after <", {
527
+ start: tagToken.start,
528
+ end: tagToken.end
529
+ });
530
+ }
531
+ const tagName = lexer.getText(tagToken);
532
+ if (channel === null) {
533
+ if (!isRootTag(tagName)) return fail("invalid-root", `the document root must be ${ROOT_TAGS.map((root) => `<${root}>`).join(", ")} — the root tag declares the channel`, {
534
+ start: tagToken.start,
535
+ end: tagToken.end
536
+ });
537
+ channel = CHANNEL_BY_ROOT_TAG[tagName];
538
+ specs = CHANNEL_TAG_SPECS[channel];
539
+ }
540
+ const tag = SML_TAGS.find((candidate) => candidate === tagName);
541
+ const spec = tag === void 0 ? void 0 : specs[tag];
542
+ if (tag === void 0 || spec === void 0) {
543
+ const channelTags = Object.keys(specs);
544
+ const suggestion = channelTags.find((candidate) => candidate.toLowerCase() === tagName.toLowerCase());
545
+ const hint = suggestion !== void 0 ? `did you mean <${suggestion}>?` : tag !== void 0 ? `<${tag}> is not part of ${channel} documents; ${channel} tags are ${listTags(channelTags)}` : `${channel} tags are ${listTags(channelTags)}`;
546
+ return fail("unknown-tag", `unknown tag <${tagName}> — ${hint}`, {
547
+ start: tagToken.start,
548
+ end: tagToken.end
549
+ });
550
+ }
551
+ const element = {
552
+ kind: "element",
553
+ id: "",
554
+ tag,
555
+ attributes: [],
556
+ children: [],
557
+ span: {
558
+ start: lessThanToken.start,
559
+ end: lessThanToken.end
560
+ }
561
+ };
562
+ let selfClosing = false;
563
+ for (;;) {
564
+ const token = lexer.scanTagToken();
565
+ if (token.kind === TokenKind.Identifier) {
566
+ const name = lexer.getText(token);
567
+ const universal = channel === "email" && UNIVERSAL_ATTRIBUTE_TAGS.has(tag);
568
+ const attributeSpec = spec.attributes[name] ?? (universal ? universalAttributeSpec(name) : void 0);
569
+ if (attributeSpec === void 0) {
570
+ const known = [...Object.keys(spec.attributes), ...universal ? Object.keys(UNIVERSAL_ATTRIBUTES) : []];
571
+ const hint = known.length === 0 ? `<${tag}> takes no attributes` : `<${tag}> accepts ${known.map((k) => `"${k}"`).join(", ")}`;
572
+ return fail("unknown-attribute", `unknown attribute "${name}" on <${tag}> — ${hint}`, {
573
+ start: token.start,
574
+ end: token.end
575
+ });
576
+ }
577
+ if (element.attributes.some((attribute) => attribute.name === name)) return fail("duplicate-attribute", `duplicate attribute "${name}" on <${tag}> — remove one`, {
578
+ start: token.start,
579
+ end: token.end
580
+ });
581
+ const parsed = parseAttributeValue(tag, name, attributeSpec, token);
582
+ if (parsed === null) return null;
583
+ const attribute = {
584
+ name,
585
+ value: parsed.value,
586
+ span: {
587
+ start: token.start,
588
+ end: parsed.valueSpan.end + 1
589
+ },
590
+ valueSpan: parsed.valueSpan
591
+ };
592
+ if (!validateNumberBounds(tag, name, attributeSpec, attribute)) return null;
593
+ element.attributes.push(attribute);
594
+ continue;
595
+ }
596
+ if (token.kind === TokenKind.SlashGreaterThan) {
597
+ selfClosing = true;
598
+ element.span.end = token.end;
599
+ break;
600
+ }
601
+ if (token.kind === TokenKind.GreaterThan) {
602
+ element.span.end = token.end;
603
+ break;
604
+ }
605
+ if (token.kind === TokenKind.EndOfFile) return fail("unclosed-tag", `unclosed <${tag}> — add </${tag}>`, {
606
+ start: lessThanToken.start,
607
+ end: tagToken.end
608
+ });
609
+ return failToken(token);
610
+ }
611
+ for (const [name, attributeSpec] of Object.entries(spec.attributes)) if (attributeSpec.required === true && !element.attributes.some((a) => a.name === name)) {
612
+ const example = attributeSpec.type === "number" ? `${name}={16}` : tag === "If" ? `test="plan == 'pro'"` : `${name}="..."`;
613
+ return fail("missing-attribute", `<${tag}> requires the "${name}" attribute — add ${example}`, {
614
+ start: lessThanToken.start,
615
+ end: element.span.end
616
+ });
617
+ }
618
+ if (tag === "If") {
619
+ const testIndex = element.attributes.findIndex((attribute) => attribute.name === "test");
620
+ const testAttribute = element.attributes[testIndex];
621
+ if (testAttribute.value.kind === "string") {
622
+ const parsed = parsePredicate(testAttribute.value.value, testAttribute.valueSpan.start);
623
+ if (Result.isFailure(parsed)) {
624
+ const first = parsed.failure[0];
625
+ return fail(first.code, first.message, first.span);
626
+ }
627
+ element.attributes[testIndex] = {
628
+ ...testAttribute,
629
+ value: {
630
+ kind: "string",
631
+ value: printPredicate(parsed.success)
632
+ }
633
+ };
634
+ }
635
+ }
636
+ if (spec.content === "void" && !selfClosing) return fail("void-needs-self-close", `<${tag}> is self-closing — write <${tag} ... />`, {
637
+ start: lessThanToken.start,
638
+ end: element.span.end
639
+ });
640
+ if (selfClosing) {
641
+ if (spec.content === "whatsapp") return fail("whatsapp-missing-body", "<WhatsApp> requires a <Body> component — add <Body>...</Body>", element.span);
642
+ return element;
643
+ }
644
+ const model = spec.content;
645
+ for (;;) {
646
+ const token = lexer.scanTextToken();
647
+ if (token.kind === TokenKind.EndOfFile) return fail("unclosed-tag", `unclosed <${tag}> — add </${tag}>`, {
648
+ start: lessThanToken.start,
649
+ end: tagToken.end
650
+ });
651
+ if (token.kind === TokenKind.Unknown) return failToken(token);
652
+ const looseChildMessage = (label) => {
653
+ if (model === "image") return `${label} is not allowed directly inside <${tag}> — <${tag}> may contain only ${listTags(allowedChildren(model))}`;
654
+ const wrapper = WRAPPER_TAG_BY_MODEL[model] ?? "Text";
655
+ return `${label} is not allowed directly inside <${tag}> — wrap it in <${wrapper}>`;
656
+ };
657
+ const afterElse = model === "verbatim-if" && element.children.some((existing) => existing.kind === "element" && existing.tag === "Else");
658
+ if (token.kind === TokenKind.Text) {
659
+ const value = lexer.getTextValue(token);
660
+ if (admitsText(model)) {
661
+ if (afterElse) return fail("else-not-last", "content after <Else> — move it inside <Else> or above it", {
662
+ start: token.start,
663
+ end: token.end
664
+ });
665
+ element.children.push({
666
+ kind: "text",
667
+ id: "",
668
+ value,
669
+ span: {
670
+ start: token.start,
671
+ end: token.end
672
+ }
673
+ });
674
+ } else if (value.trim().length > 0) return fail("invalid-child", looseChildMessage("text"), {
675
+ start: token.start,
676
+ end: token.end
677
+ });
678
+ continue;
679
+ }
680
+ if (token.kind === TokenKind.Interpolation) {
681
+ if (!admitsText(model)) return fail("invalid-child", looseChildMessage(`{{${token.name}}}`), {
682
+ start: token.start,
683
+ end: token.end
684
+ });
685
+ if (afterElse) return fail("else-not-last", "content after <Else> — move it inside <Else> or above it", {
686
+ start: token.start,
687
+ end: token.end
688
+ });
689
+ element.children.push({
690
+ kind: "interpolation",
691
+ id: "",
692
+ name: token.name,
693
+ syntax: token.syntax,
694
+ span: {
695
+ start: token.start,
696
+ end: token.end
697
+ }
698
+ });
699
+ continue;
700
+ }
701
+ if (token.kind === TokenKind.LessThanSlash) {
702
+ const closeName = lexer.scanTagToken();
703
+ if (closeName.kind !== TokenKind.Identifier || lexer.getText(closeName) !== tag) {
704
+ const found = closeName.kind === TokenKind.Identifier ? `</${lexer.getText(closeName)}>` : "</>";
705
+ return fail("mismatched-close-tag", `expected </${tag}> but found ${found} — close <${tag}> first`, {
706
+ start: token.start,
707
+ end: closeName.end
708
+ });
709
+ }
710
+ const closeEnd = lexer.scanTagToken();
711
+ if (closeEnd.kind !== TokenKind.GreaterThan) return fail("mismatched-close-tag", `expected > to finish </${tag}>`, {
712
+ start: closeEnd.start,
713
+ end: closeEnd.end
714
+ });
715
+ element.span.end = closeEnd.end;
716
+ break;
717
+ }
718
+ const allowed = allowedChildren(model);
719
+ const child = parseElement(token);
720
+ if (child === null) return null;
721
+ if (!allowed.includes(child.tag)) {
722
+ const message = child.tag === "Else" ? `<Else> is only valid as the last child of <If> — wrap this branch in <If>` : isInlineModel(model) ? child.tag === "a" ? `<a> is not allowed inside <${tag}> — links cannot nest inside links or buttons` : `<${child.tag}> is not allowed inside <${tag}> — only text, {{variables}}${allowed.length > 0 ? `, and ${listTags(allowed)}` : ""} may appear here` : `<${child.tag}> is not allowed inside <${tag}> — <${tag}> may contain only ${listTags(allowed)}`;
723
+ return fail("invalid-child", message, child.span);
724
+ }
725
+ if (model === "email" && EMAIL_ENVELOPE_CHILDREN.includes(child.tag)) {
726
+ if (element.children.some((existing) => existing.kind === "element" && existing.tag === child.tag)) return fail(`${child.tag.toLowerCase()}-duplicate`, `<Email> allows at most one <${child.tag}> — merge them`, child.span);
727
+ if (element.children.some((existing) => existing.kind === "element" && !EMAIL_ENVELOPE_CHILDREN.includes(existing.tag))) return fail(`${child.tag.toLowerCase()}-position`, `<${child.tag}> must appear before the body blocks in <Email> — move it above the other blocks`, child.span);
728
+ }
729
+ if ((model === "if" || model === "verbatim-if") && element.children.some((existing) => existing.kind === "element" && existing.tag === "Else")) return fail(child.tag === "Else" ? "duplicate-else" : "else-not-last", child.tag === "Else" ? "only one <Else> is allowed inside <If> — merge the branches" : `<Else> must be the last child of <If> — move <${child.tag}> above it`, child.span);
730
+ if (model === "whatsapp") {
731
+ const rank = {
732
+ Header: 0,
733
+ Body: 1,
734
+ Footer: 2,
735
+ Buttons: 3
736
+ };
737
+ const childRank = rank[child.tag];
738
+ for (const existing of element.children) {
739
+ if (existing.kind !== "element") continue;
740
+ if (existing.tag === child.tag) return fail("whatsapp-duplicate-component", `<WhatsApp> allows at most one <${child.tag}> — merge them`, child.span);
741
+ if (rank[existing.tag] > childRank) return fail("whatsapp-component-order", `<${child.tag}> must come before <${existing.tag}> — WhatsApp components are Header?, Body, Footer?, Buttons? in that order`, child.span);
742
+ }
743
+ }
744
+ if (model === "image") {
745
+ const scheme = child.attributes.find((attribute) => attribute.name === "scheme");
746
+ if (scheme?.value.kind === "string" && element.children.some((existing) => existing.kind === "element" && existing.tag === "Source" && existing.attributes.some((attribute) => attribute.name === "scheme" && attribute.value.kind === "string" && attribute.value.value === scheme.value.value))) return fail("duplicate-source", `<Image> allows one <Source scheme="${scheme.value.value}">; remove the duplicate`, child.span);
747
+ }
748
+ element.children.push(child);
749
+ }
750
+ if (model === "whatsapp") {
751
+ if (!element.children.some((child) => child.kind === "element" && child.tag === "Body")) return fail("whatsapp-missing-body", "<WhatsApp> requires a <Body> component — add <Body>...</Body>", element.span);
752
+ }
753
+ if (isInlineModel(model)) normalizeInlineChildren(element);
754
+ return element;
755
+ };
756
+ const skipBlankText = () => {
757
+ for (;;) {
758
+ const token = lexer.scanTextToken();
759
+ if (token.kind === TokenKind.Text && lexer.getTextValue(token).trim().length === 0) continue;
760
+ return token;
761
+ }
762
+ };
763
+ const first = skipBlankText();
764
+ if (first.kind === TokenKind.Unknown) failToken(first);
765
+ else if (first.kind !== TokenKind.LessThan) fail("invalid-root", `the document must start with a channel root element: ${ROOT_TAGS.map((root) => `<${root}>`).join(", ")}`, {
766
+ start: first.start,
767
+ end: first.end
768
+ });
769
+ let root = null;
770
+ if (diagnostic === void 0) root = parseElement(first);
771
+ if (root !== null) {
772
+ const trailing = skipBlankText();
773
+ if (trailing.kind !== TokenKind.EndOfFile) {
774
+ fail("multiple-roots", `content after </${root.tag}> — a document has exactly one root element`, {
775
+ start: trailing.start,
776
+ end: trailing.end
777
+ });
778
+ root = null;
779
+ }
780
+ }
781
+ if (root === null || diagnostic !== void 0) return Result.fail(new SmlParseError({ diagnostics: [diagnostic ?? {
782
+ code: "parse-failed",
783
+ message: "parse failed",
784
+ span: {
785
+ start: 0,
786
+ end: 0
787
+ }
788
+ }] }));
789
+ assignNodeIds(root);
790
+ return Result.succeed({
791
+ kind: "document",
792
+ root
793
+ });
794
+ };
795
+ /**
796
+ * Canonical inline whitespace: collapse runs to a single space, trim at the
797
+ * element boundary, drop text nodes that end up empty. Makes parse ∘ print a
798
+ * fixpoint — the printer emits exactly these values.
799
+ */
800
+ const normalizeInlineChildren = (element) => {
801
+ for (const child of element.children) if (child.kind === "text") child.value = child.value.replace(/\s+/g, " ");
802
+ const firstChild = element.children[0];
803
+ if (firstChild?.kind === "text") firstChild.value = firstChild.value.replace(/^ /, "");
804
+ const lastChild = element.children[element.children.length - 1];
805
+ if (lastChild?.kind === "text") lastChild.value = lastChild.value.replace(/ $/, "");
806
+ element.children = element.children.filter((child) => child.kind !== "text" || child.value.length > 0);
807
+ };
808
+ /** Stable ids in document order — identical source ⇒ identical ids. */
809
+ const assignNodeIds = (root) => {
810
+ let next = 0;
811
+ const visit = (node) => {
812
+ node.id = `n${next}`;
813
+ next++;
814
+ if (node.kind === "element") for (const child of node.children) visit(child);
815
+ };
816
+ visit(root);
817
+ };
818
+ //#endregion
819
+ export { CHANNEL_TAG_SPECS, EMAIL_TAG_SPECS, SMS_TAG_SPECS, UNIVERSAL_ATTRIBUTES, UNIVERSAL_ATTRIBUTE_TAGS, WHATSAPP_TAG_SPECS, allowedChildren, parseSml };
820
+
821
+ //# sourceMappingURL=parser.js.map