@templatical/import-beefree 0.54.1 → 0.55.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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { canonicalFontStack, createButtonBlock, createDefaultTemplateContent, createDividerBlock, createHtmlBlock, createImageBlock, createMenuBlock, createParagraphBlock, createSectionBlock, createSocialIconsBlock, createSpacerBlock, createTableBlock, createTitleBlock, createVideoBlock, generateId, isLocaleTag, isSafeHtmlUrl, sourceHash } from "@templatical/types";
1
+ import { BORDER_SIDES, BUILT_IN_FONT_STACKS, HEADING_LEVEL_FONT_SIZE, canonicalFontStack, createButtonBlock, createDefaultTemplateContent, createDividerBlock, createHtmlBlock, createImageBlock, createMenuBlock, createParagraphBlock, createSectionBlock, createSocialIconsBlock, createSpacerBlock, createTableBlock, createTitleBlock, createVideoBlock, findOpenTagEnd, generateId, isGenericFontFamily, isImage, isLocaleTag, isSafeHtmlUrl, parseFontFamilies, sourceHash, toBorderCss } from "@templatical/types";
2
2
  // src/localization.ts
3
3
  function warn(warnings, message) {
4
4
  if (!warnings.includes(message)) warnings.push(message);
@@ -51,6 +51,718 @@ function registerLocales(content) {
51
51
  walk(content.blocks);
52
52
  if (locales.size) content.settings.locales = [...locales];
53
53
  }
54
+ // src/merge-tokens.ts
55
+ /**
56
+ * Character references Beefree writes inside `{{ }}`, `{% %}` and `{# #}`.
57
+ * A sender parses those tokens before an HTML parser does, so `'` has to
58
+ * be an apostrophe in the imported string. References outside a token stay
59
+ * encoded. An opener with no closer is left as written. Each reference is
60
+ * decoded once: `"` becomes `"`.
61
+ *
62
+ * The three kinds are scanned with `indexOf`. The earliest opener wins, and
63
+ * the match ends at that kind's first closer. A kind with no closer can never
64
+ * match later in the string, so it is not searched again.
65
+ */
66
+ const TOKEN_KINDS = [
67
+ {
68
+ opener: "{{",
69
+ closer: "}}"
70
+ },
71
+ {
72
+ opener: "{%",
73
+ closer: "%}"
74
+ },
75
+ {
76
+ opener: "{#",
77
+ closer: "#}"
78
+ }
79
+ ];
80
+ function tokenSeeks() {
81
+ return TOKEN_KINDS.map((kind) => ({
82
+ opener: kind.opener,
83
+ closer: kind.closer,
84
+ at: -1,
85
+ searched: false,
86
+ dead: false
87
+ }));
88
+ }
89
+ /** Next opener of this kind at or after `from`, cached so each index is visited once. */
90
+ function openerAt(value, seek, from) {
91
+ if (seek.dead) return -1;
92
+ if (seek.searched && (seek.at === -1 || seek.at >= from)) return seek.at;
93
+ seek.at = value.indexOf(seek.opener, from);
94
+ seek.searched = true;
95
+ if (seek.at === -1) seek.dead = true;
96
+ return seek.at;
97
+ }
98
+ /**
99
+ * The next token at or after `from`, or `null` when none remain.
100
+ * An unterminated opener stays in the text: the scan resumes one character
101
+ * later, and that opener kind is not tried again.
102
+ */
103
+ function nextToken(value, from, seeks) {
104
+ let cursor = from;
105
+ while (cursor < value.length) {
106
+ let best = -1;
107
+ let kind = -1;
108
+ for (let i = 0; i < seeks.length; i++) {
109
+ const at = openerAt(value, seeks[i], cursor);
110
+ if (at === -1) continue;
111
+ if (best === -1 || at < best) {
112
+ best = at;
113
+ kind = i;
114
+ }
115
+ }
116
+ if (kind === -1 || best === -1) break;
117
+ const seek = seeks[kind];
118
+ const closeAt = value.indexOf(seek.closer, best + seek.opener.length);
119
+ if (closeAt === -1) {
120
+ seek.dead = true;
121
+ cursor = best + 1;
122
+ continue;
123
+ }
124
+ return {
125
+ start: best,
126
+ end: closeAt + seek.closer.length
127
+ };
128
+ }
129
+ return null;
130
+ }
131
+ function tokenSpans(value) {
132
+ const seeks = tokenSeeks();
133
+ const spans = [];
134
+ let from = 0;
135
+ while (from < value.length) {
136
+ const span = nextToken(value, from, seeks);
137
+ if (!span) break;
138
+ spans.push(span);
139
+ from = span.end;
140
+ }
141
+ return spans;
142
+ }
143
+ const CHAR_REF = /&(?:amp|lt|gt|quot|apos|nbsp|#x[0-9a-f]+|#\d+);/gi;
144
+ const NAMED = {
145
+ amp: "&",
146
+ lt: "<",
147
+ gt: ">",
148
+ quot: "\"",
149
+ apos: "'",
150
+ nbsp: "\xA0"
151
+ };
152
+ /** A scalar character, or the reference itself when the code point is not one. */
153
+ function decodeCharRef(entity) {
154
+ const body = entity.slice(1, -1);
155
+ if (body[0] !== "#") return NAMED[body.toLowerCase()] ?? entity;
156
+ const hex = body[1] === "x" || body[1] === "X";
157
+ const code = Number.parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10);
158
+ if (!Number.isInteger(code) || code < 1 || code > 1114111 || code >= 55296 && code <= 57343) return entity;
159
+ return String.fromCodePoint(code);
160
+ }
161
+ function decodeToken(token, preserve) {
162
+ CHAR_REF.lastIndex = 0;
163
+ return token.replace(CHAR_REF, (entity) => {
164
+ const char = decodeCharRef(entity);
165
+ return preserve?.(char) ?? char;
166
+ }).replace(/\u00A0/g, (char) => preserve?.(char) ?? " ");
167
+ }
168
+ function decodeEntities(value, preserve, ctx) {
169
+ const seeks = tokenSeeks();
170
+ const parts = [];
171
+ let i = 0;
172
+ let from = 0;
173
+ let found = false;
174
+ while (from < value.length) {
175
+ const span = nextToken(value, from, seeks);
176
+ if (!span) break;
177
+ found = true;
178
+ if (span.start > i) parts.push(value.slice(i, span.start));
179
+ parts.push(ctx ? decodeTokenAt(value, span.start, span.end, ctx) : decodeToken(value.slice(span.start, span.end), preserve));
180
+ i = span.end;
181
+ from = span.end;
182
+ }
183
+ if (!found) return value;
184
+ if (i < value.length) parts.push(value.slice(i));
185
+ return parts.join("");
186
+ }
187
+ function decodeEntitiesInTokens(value) {
188
+ return decodeEntities(value);
189
+ }
190
+ /** A quote stays `&quot;` so it cannot end a double-quoted attribute. */
191
+ const preserveDoubleQuote = (char) => char === "\"" ? "&quot;" : void 0;
192
+ /** An apostrophe stays `&#39;` so it cannot end a single-quoted attribute. */
193
+ const preserveSingleQuote = (char) => char === "'" ? "&#39;" : void 0;
194
+ /**
195
+ * Characters that end an unquoted attribute (`whitespace` or `>`) or that
196
+ * a loose parser treats as a quote. A decoded `&nbsp;` is one of those spaces.
197
+ */
198
+ const preserveUnquoted = (char) => {
199
+ switch (char) {
200
+ case "\"": return "&quot;";
201
+ case "'": return "&#39;";
202
+ case ">": return "&gt;";
203
+ case " ": return "&#32;";
204
+ case " ": return "&#9;";
205
+ case "\n": return "&#10;";
206
+ case "\r": return "&#13;";
207
+ case "\f": return "&#12;";
208
+ case "\xA0": return "&nbsp;";
209
+ default: return;
210
+ }
211
+ };
212
+ /** Text or a comment: decode a reference in full. */
213
+ const CTX_TEXT = 0;
214
+ /** A double-quoted attribute value. */
215
+ const CTX_DQ = 1;
216
+ /** A single-quoted attribute value. */
217
+ const CTX_SQ = 2;
218
+ /** An unquoted attribute value. */
219
+ const CTX_UQ = 3;
220
+ /** Tag markup outside an attribute value. A reference here stays encoded. */
221
+ const CTX_OPAQUE = 4;
222
+ const PRESERVE_BY_KIND = [];
223
+ PRESERVE_BY_KIND[CTX_TEXT] = void 0;
224
+ PRESERVE_BY_KIND[CTX_DQ] = preserveDoubleQuote;
225
+ PRESERVE_BY_KIND[CTX_SQ] = preserveSingleQuote;
226
+ PRESERVE_BY_KIND[CTX_UQ] = preserveUnquoted;
227
+ function replacementFor(char, kind) {
228
+ const preserve = PRESERVE_BY_KIND[kind];
229
+ return preserve?.(char) ?? (char === "\xA0" ? " " : char);
230
+ }
231
+ /** Copy `slice[from, to)`, turning a literal U+00A0 into a space inside a token. */
232
+ function copyPlain(slice, from, to, base, ctx) {
233
+ const nbsp = slice.indexOf("\xA0", from);
234
+ if (nbsp === -1 || nbsp >= to) return slice.slice(from, to);
235
+ const parts = [];
236
+ let i = from;
237
+ while (i < to) {
238
+ const at = slice.indexOf("\xA0", i);
239
+ if (at === -1 || at >= to) {
240
+ parts.push(slice.slice(i, to));
241
+ break;
242
+ }
243
+ if (at > i) parts.push(slice.slice(i, at));
244
+ const kind = ctx[base + at];
245
+ parts.push(kind === CTX_OPAQUE ? "\xA0" : replacementFor("\xA0", kind));
246
+ i = at + 1;
247
+ }
248
+ return parts.join("");
249
+ }
250
+ /**
251
+ * Decode references in `html[start, end)`. Each reference uses the context
252
+ * of its own index, so one token can span a text node and an attribute.
253
+ */
254
+ function decodeTokenAt(html, start, end, ctx) {
255
+ const slice = html.slice(start, end);
256
+ if (slice.indexOf("&") === -1 && slice.indexOf("\xA0") === -1) return slice;
257
+ const parts = [];
258
+ CHAR_REF.lastIndex = 0;
259
+ let last = 0;
260
+ let match;
261
+ while (match = CHAR_REF.exec(slice)) {
262
+ parts.push(copyPlain(slice, last, match.index, start, ctx));
263
+ const kind = ctx[start + match.index];
264
+ parts.push(kind === CTX_OPAQUE ? match[0] : replacementFor(decodeCharRef(match[0]), kind));
265
+ last = match.index + match[0].length;
266
+ }
267
+ parts.push(copyPlain(slice, last, slice.length, start, ctx));
268
+ return parts.join("");
269
+ }
270
+ /** Paint the tag opaque, then lay each attribute value back over it. */
271
+ function paintTag(ctx, start, end, values) {
272
+ ctx.fill(CTX_OPAQUE, start, end);
273
+ for (const value of values) ctx.fill(value.kind, value.start, value.end);
274
+ return end;
275
+ }
276
+ /**
277
+ * Mark attribute values on the tag at `start`. `null` when the tag never
278
+ * closes: the caller leaves that tail as written.
279
+ */
280
+ function markTag(html, start, ctx) {
281
+ const values = [];
282
+ let i = start + 1;
283
+ let state = "tag";
284
+ let quote = "";
285
+ let valueStart = 0;
286
+ while (i < html.length) {
287
+ const char = html[i];
288
+ if (state === "quoted") {
289
+ if (char !== quote) {
290
+ i++;
291
+ continue;
292
+ }
293
+ values.push({
294
+ start: valueStart,
295
+ end: i,
296
+ kind: quote === "\"" ? CTX_DQ : CTX_SQ
297
+ });
298
+ state = "tag";
299
+ i++;
300
+ continue;
301
+ }
302
+ if (state === "unquoted") {
303
+ if (char !== ">" && !isSpace(char)) {
304
+ i++;
305
+ continue;
306
+ }
307
+ values.push({
308
+ start: valueStart,
309
+ end: i,
310
+ kind: CTX_UQ
311
+ });
312
+ state = "tag";
313
+ if (char === ">") return paintTag(ctx, start, i + 1, values);
314
+ i++;
315
+ continue;
316
+ }
317
+ if (state === "before") {
318
+ if (isSpace(char)) {
319
+ i++;
320
+ continue;
321
+ }
322
+ if (char === ">") return paintTag(ctx, start, i + 1, values);
323
+ if (char === "\"" || char === "'") {
324
+ quote = char;
325
+ valueStart = i + 1;
326
+ state = "quoted";
327
+ i++;
328
+ continue;
329
+ }
330
+ valueStart = i;
331
+ state = "unquoted";
332
+ continue;
333
+ }
334
+ if (char === "=") {
335
+ state = "before";
336
+ i++;
337
+ continue;
338
+ }
339
+ if (char === ">") return paintTag(ctx, start, i + 1, values);
340
+ i++;
341
+ }
342
+ return null;
343
+ }
344
+ /**
345
+ * Context of each index. Text and comments stay `CTX_TEXT`. An attribute
346
+ * value is marked for its quotes. The rest of a closed tag is opaque.
347
+ * `limit` stops the token scan at an unclosed tag, which is left as written.
348
+ */
349
+ function markHtmlContext(html) {
350
+ const ctx = new Uint8Array(html.length);
351
+ let i = 0;
352
+ while (i < html.length) {
353
+ const lt = html.indexOf("<", i);
354
+ if (lt === -1) break;
355
+ if (html.startsWith("<!--", lt)) {
356
+ const end = html.indexOf("-->", lt + 4);
357
+ if (end === -1) break;
358
+ i = end + 3;
359
+ continue;
360
+ }
361
+ const next = markTag(html, lt, ctx);
362
+ if (next === null) return {
363
+ ctx,
364
+ limit: lt
365
+ };
366
+ i = next;
367
+ }
368
+ return {
369
+ ctx,
370
+ limit: html.length
371
+ };
372
+ }
373
+ /**
374
+ * Decode tokens in HTML. A token is the earliest `{{`, `{%` or `{#` through
375
+ * its first closer, and it may span tags. A reference in text or a comment
376
+ * is decoded in full, so a quote there is a quote and U+00A0 is a space.
377
+ * A reference inside an attribute is decoded for the quotes that wrap it:
378
+ * a double-quoted value keeps `"`, a single-quoted value keeps `'`, and an
379
+ * unquoted value keeps the characters that end it. A reference in the tag
380
+ * itself stays encoded. An unclosed tag is left as written, and a token
381
+ * does not close inside it.
382
+ */
383
+ function decodeEntitiesInHtml(html) {
384
+ if (html.indexOf("<") === -1) return decodeEntitiesInTokens(html);
385
+ const marked = markHtmlContext(html);
386
+ const decoded = decodeEntities(marked.limit === html.length ? html : html.slice(0, marked.limit), void 0, marked.ctx);
387
+ return marked.limit === html.length ? decoded : decoded + html.slice(marked.limit);
388
+ }
389
+ /**
390
+ * Every character reference in plain text, decoded once. A button label is
391
+ * not HTML: the renderer escapes it, so `&nbsp;` has to be the character or
392
+ * the mail shows the letters `&amp;nbsp;`.
393
+ */
394
+ function decodePlainTextEntities(value) {
395
+ CHAR_REF.lastIndex = 0;
396
+ return value.replace(CHAR_REF, decodeCharRef);
397
+ }
398
+ /**
399
+ * BeeFree writes a non-breaking space as `&amp;nbsp;`. The extra `&amp;`
400
+ * leaves the letters `&nbsp;` in the mail. Put back the single reference.
401
+ * Any other reference outside a token stays as written.
402
+ */
403
+ function restoreDoubledNbsp(html) {
404
+ return html.replace(/&amp;(nbsp;|#0*160;|#x0*a0;)/gi, "&$1");
405
+ }
406
+ /** Whitespace, or a reference that decodes to whitespace, up to the extra `&amp;` on `&amp;nbsp;`. */
407
+ function isSpacePadding(text) {
408
+ let current = text;
409
+ for (let pass = 0; pass < 2; pass++) {
410
+ CHAR_REF.lastIndex = 0;
411
+ const next = current.replace(CHAR_REF, decodeCharRef);
412
+ if (next === current) break;
413
+ current = next;
414
+ }
415
+ return /^[\s\u00A0]*$/.test(current);
416
+ }
417
+ /**
418
+ * A chip between two merge tags holds only a non-breaking space (kept as one
419
+ * plain space), and a chip
420
+ * around one token leads with plain spaces that are not part of the token.
421
+ * A non-breaking space in front of that token stays: it is the space between
422
+ * the previous word and the token. A chip
423
+ * that also carries words, or more than one token, is left as written. A
424
+ * space after the token stays: it is the space the author typed.
425
+ */
426
+ /** Plain spaces the editor pads in front of a token. A non-breaking space is not padding. */
427
+ function isPlainSpacePadding(text) {
428
+ return /^[ \t\n\r\f]*$/.test(text);
429
+ }
430
+ /** Drop a plain leading space before the one token in `text`. A non-breaking space stays. */
431
+ function withoutLeadingTokenPadding(text) {
432
+ const spans = tokenSpans(text);
433
+ if (spans.length !== 1) return text;
434
+ const start = spans[0].start;
435
+ if (!isPlainSpacePadding(text.slice(0, start))) return text;
436
+ return text.slice(start);
437
+ }
438
+ /**
439
+ * What a mergetag chip keeps once nested chips are already unwrapped.
440
+ * A chip that only holds a space between two tokens is that space. A plain
441
+ * leading space before the one token is padding and drops.
442
+ */
443
+ function keptMergetagText(text) {
444
+ if (tokenSpans(text).length !== 1) return text !== "" && isSpacePadding(text) ? " " : text;
445
+ return withoutLeadingTokenPadding(text);
446
+ }
447
+ function fold(char) {
448
+ const code = char.charCodeAt(0);
449
+ return code >= 65 && code <= 90 ? String.fromCharCode(code + 32) : char;
450
+ }
451
+ function equalsIgnoreCase(value, literal) {
452
+ if (value.length !== literal.length) return false;
453
+ for (let i = 0; i < value.length; i++) if (fold(value[i]) !== fold(literal[i])) return false;
454
+ return true;
455
+ }
456
+ function startsWithIgnoreCase(html, at, literal) {
457
+ if (at < 0 || at + literal.length > html.length) return false;
458
+ for (let i = 0; i < literal.length; i++) if (fold(html[at + i]) !== fold(literal[i])) return false;
459
+ return true;
460
+ }
461
+ function isSpace(char) {
462
+ return char === " " || char === " " || char === "\n" || char === "\r" || char === "\f";
463
+ }
464
+ /** A character that continues an HTML tag name (`code` vs `codebook`). */
465
+ function isNameChar(char) {
466
+ if (char === void 0) return false;
467
+ const code = char.charCodeAt(0);
468
+ return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122 || char === "-" || char === ":" || char === "_";
469
+ }
470
+ const CODE_TAG = "code";
471
+ /**
472
+ * Index of the next `<code` tag at or after `from` and before `limit`, or -1.
473
+ * `<codebook` is not one. `limit` keeps a chip from scanning past its closer.
474
+ */
475
+ function indexOfCodeOpen(html, from, limit = html.length) {
476
+ let i = from;
477
+ while (i < limit) {
478
+ const at = html.indexOf("<", i);
479
+ if (at === -1 || at >= limit) return -1;
480
+ if (startsWithIgnoreCase(html, at + 1, CODE_TAG) && !isNameChar(html[at + 1 + 4])) return at;
481
+ i = at + 1;
482
+ }
483
+ return -1;
484
+ }
485
+ /**
486
+ * Index just after the `>` that ends the tag at `open`. A quoted `>` is part
487
+ * of an attribute (`data-bee-code="{% if n > 1 %}"`), not the end of the tag.
488
+ * -1 when the tag never closes.
489
+ */
490
+ function endOfTag(html, open) {
491
+ let quote = null;
492
+ for (let i = open + 1; i < html.length; i++) {
493
+ const char = html[i];
494
+ if (quote) {
495
+ if (char === quote) quote = null;
496
+ continue;
497
+ }
498
+ if (char === "\"" || char === "'") {
499
+ quote = char;
500
+ continue;
501
+ }
502
+ if (char === ">") return i + 1;
503
+ }
504
+ return -1;
505
+ }
506
+ /** `<code … />`, with whitespace allowed before the slash. */
507
+ function isSelfClosing(html, openEnd) {
508
+ let i = openEnd - 2;
509
+ while (i >= 0 && isSpace(html[i])) i--;
510
+ return html[i] === "/";
511
+ }
512
+ /**
513
+ * The open tag carries `data-bee-type="mergetag"`. The attribute may sit
514
+ * anywhere, in double quotes, single quotes, or none.
515
+ */
516
+ function isMergetagOpen(html, open, openEnd) {
517
+ let i = open + 1 + 4;
518
+ const end = openEnd - 1;
519
+ while (i < end) {
520
+ while (i < end && isSpace(html[i])) i++;
521
+ if (i >= end || html[i] === "/") break;
522
+ const nameStart = i;
523
+ while (i < end && !isSpace(html[i]) && html[i] !== "=" && html[i] !== "/") i++;
524
+ const name = html.slice(nameStart, i);
525
+ while (i < end && isSpace(html[i])) i++;
526
+ let value = "";
527
+ if (html[i] === "=") {
528
+ i++;
529
+ while (i < end && isSpace(html[i])) i++;
530
+ const quote = html[i];
531
+ if (quote === "\"" || quote === "'") {
532
+ i++;
533
+ const valueStart = i;
534
+ while (i < end && html[i] !== quote) i++;
535
+ value = html.slice(valueStart, i);
536
+ if (html[i] === quote) i++;
537
+ } else {
538
+ const valueStart = i;
539
+ while (i < end && !isSpace(html[i]) && html[i] !== "/") i++;
540
+ value = html.slice(valueStart, i);
541
+ }
542
+ }
543
+ if (equalsIgnoreCase(name, "data-bee-type") && value === "mergetag") return true;
544
+ }
545
+ return false;
546
+ }
547
+ /** Index just after the `>` of a `</code>` at `at`, or -1 when `at` is not one. */
548
+ function codeCloseEnd(html, at) {
549
+ if (!startsWithIgnoreCase(html, at + 1, `/${CODE_TAG}`)) return -1;
550
+ let j = at + 2 + 4;
551
+ while (j < html.length && isSpace(html[j])) j++;
552
+ return html[j] === ">" ? j + 1 : -1;
553
+ }
554
+ /**
555
+ * The matching `</code>` for every `<code>` that has one, counting nested
556
+ * `<code>` elements. One forward pass: an unclosed tag does not make every
557
+ * later tag scan the rest of the string.
558
+ */
559
+ function indexCodeCloses(html) {
560
+ const closes = /* @__PURE__ */ new Map();
561
+ const stack = [];
562
+ let i = 0;
563
+ while (i < html.length) {
564
+ const at = html.indexOf("<", i);
565
+ if (at === -1) break;
566
+ const closeEnd = codeCloseEnd(html, at);
567
+ if (closeEnd !== -1) {
568
+ const open = stack.pop();
569
+ if (open !== void 0) closes.set(open, {
570
+ start: at,
571
+ end: closeEnd
572
+ });
573
+ i = closeEnd;
574
+ continue;
575
+ }
576
+ if (startsWithIgnoreCase(html, at + 1, CODE_TAG) && !isNameChar(html[at + 1 + 4])) {
577
+ const openEnd = endOfTag(html, at);
578
+ if (openEnd === -1) break;
579
+ if (!isSelfClosing(html, openEnd)) stack.push(at);
580
+ i = openEnd;
581
+ continue;
582
+ }
583
+ i = at + 1;
584
+ }
585
+ return closes;
586
+ }
587
+ /** `{` can open a token. `&` can be a character reference. Both need the joined text. */
588
+ function pieceFlags(text) {
589
+ let brace = false;
590
+ let dirty = false;
591
+ let blank = true;
592
+ for (let i = 0; i < text.length; i++) {
593
+ const char = text[i];
594
+ if (char === "{") brace = true;
595
+ else if (char === "&") dirty = true;
596
+ if (blank && !/\s/.test(char)) blank = false;
597
+ if (brace && dirty && !blank) break;
598
+ }
599
+ if (text.length === 0) blank = true;
600
+ return {
601
+ brace,
602
+ dirty,
603
+ blank,
604
+ len: text.length
605
+ };
606
+ }
607
+ function flagsOf(parts) {
608
+ let brace = false;
609
+ let dirty = false;
610
+ let blank = true;
611
+ let len = 0;
612
+ for (const part of parts) {
613
+ const flags = typeof part === "string" ? pieceFlags(part) : part;
614
+ brace = brace || flags.brace;
615
+ dirty = dirty || flags.dirty;
616
+ blank = blank && flags.blank;
617
+ len += flags.len;
618
+ }
619
+ return {
620
+ brace,
621
+ dirty,
622
+ blank,
623
+ len
624
+ };
625
+ }
626
+ function flattenRope(parts) {
627
+ const out = [];
628
+ const lists = [parts];
629
+ const index = [0];
630
+ while (lists.length > 0) {
631
+ const list = lists[lists.length - 1];
632
+ const at = index[index.length - 1];
633
+ if (at >= list.length) {
634
+ lists.pop();
635
+ index.pop();
636
+ continue;
637
+ }
638
+ index[index.length - 1] = at + 1;
639
+ const piece = list[at];
640
+ if (typeof piece === "string") out.push(piece);
641
+ else {
642
+ lists.push(piece.parts);
643
+ index.push(0);
644
+ }
645
+ }
646
+ return out.join("");
647
+ }
648
+ /**
649
+ * A closed chip's text. Nested chips are already ropes, so a chain of chips
650
+ * that is not a token or a padding space is not joined until the root.
651
+ */
652
+ function finishMergetag(parts) {
653
+ const flags = flagsOf(parts);
654
+ if (!flags.brace && !flags.dirty) {
655
+ if (flags.len === 0) return "";
656
+ if (flags.blank) return " ";
657
+ if (parts.length === 1) return parts[0];
658
+ return {
659
+ parts,
660
+ ...flags
661
+ };
662
+ }
663
+ return keptMergetagText(flattenRope(parts));
664
+ }
665
+ /**
666
+ * Remove a `<code data-bee-type="mergetag">` wrapper and keep the text inside
667
+ * it. The editor turns a bare `{{ … }}` or `{% … %}` token in rich text into
668
+ * a merge-tag span when the template loads, which is the shape BeeFree's HTML
669
+ * export already leaves. A `<code>` without that attribute stays, including an
670
+ * inline code sample and a `data-bee-type="speciallink"` wrapper. Run this
671
+ * before `decodeEntitiesInTokens`: a token inside `data-bee-code` would
672
+ * otherwise be decoded in the attribute and break its quotes. An opener with
673
+ * no closer is left as written.
674
+ */
675
+ function unwrapMergeTagCode(html) {
676
+ if (html.indexOf("<") === -1) return html;
677
+ const closes = indexCodeCloses(html);
678
+ const root = [];
679
+ const stack = [];
680
+ const dest = () => stack.length > 0 ? stack[stack.length - 1].parts : root;
681
+ let i = 0;
682
+ while (i < html.length) {
683
+ const frame = stack.length > 0 ? stack[stack.length - 1] : void 0;
684
+ const open = indexOfCodeOpen(html, i, frame ? frame.closeStart : html.length);
685
+ if (frame && open === -1) {
686
+ if (i < frame.closeStart) frame.parts.push(html.slice(i, frame.closeStart));
687
+ const inner = finishMergetag(frame.parts);
688
+ stack.pop();
689
+ dest().push(inner);
690
+ i = frame.closeEnd;
691
+ continue;
692
+ }
693
+ if (open === -1) {
694
+ dest().push(html.slice(i));
695
+ break;
696
+ }
697
+ const openEnd = endOfTag(html, open);
698
+ if (openEnd === -1) {
699
+ dest().push(html.slice(i));
700
+ break;
701
+ }
702
+ dest().push(html.slice(i, open));
703
+ if (!isMergetagOpen(html, open, openEnd)) {
704
+ dest().push(html.slice(open, openEnd));
705
+ i = openEnd;
706
+ continue;
707
+ }
708
+ if (isSelfClosing(html, openEnd)) {
709
+ i = openEnd;
710
+ continue;
711
+ }
712
+ const close = closes.get(open);
713
+ if (close === void 0) {
714
+ dest().push(html.slice(open, openEnd));
715
+ i = openEnd;
716
+ continue;
717
+ }
718
+ stack.push({
719
+ parts: [],
720
+ closeStart: close.start,
721
+ closeEnd: close.end
722
+ });
723
+ i = openEnd;
724
+ }
725
+ return flattenRope(root);
726
+ }
727
+ /**
728
+ * A `<code>` that only highlights one token leads with plain spaces that are
729
+ * not part of the token. A non-breaking space stays. The element stays, and
730
+ * so does a space after the token.
731
+ * Nested markup inside the element is left as written.
732
+ */
733
+ function trimHighlightedTokenPadding(html) {
734
+ if (html.indexOf("<") === -1) return html;
735
+ const closes = indexCodeCloses(html);
736
+ const parts = [];
737
+ let i = 0;
738
+ while (i < html.length) {
739
+ const open = indexOfCodeOpen(html, i);
740
+ if (open === -1) {
741
+ parts.push(html.slice(i));
742
+ break;
743
+ }
744
+ const openEnd = endOfTag(html, open);
745
+ if (openEnd === -1) {
746
+ parts.push(html.slice(i));
747
+ break;
748
+ }
749
+ parts.push(html.slice(i, openEnd));
750
+ if (isSelfClosing(html, openEnd)) {
751
+ i = openEnd;
752
+ continue;
753
+ }
754
+ const close = closes.get(open);
755
+ if (close === void 0) {
756
+ i = openEnd;
757
+ continue;
758
+ }
759
+ const inner = html.slice(openEnd, close.start);
760
+ parts.push(inner.includes("<") ? inner : withoutLeadingTokenPadding(inner));
761
+ parts.push(html.slice(close.start, close.end));
762
+ i = close.end;
763
+ }
764
+ return parts.join("");
765
+ }
54
766
  // src/style-parser.ts
55
767
  /**
56
768
  * Parses CSS-like style values from BeeFree descriptors.
@@ -515,6 +1227,7 @@ const SOCIAL_PLATFORM_MAP = {
515
1227
  x: "twitter",
516
1228
  instagram: "instagram",
517
1229
  linkedin: "linkedin",
1230
+ xing: "xing",
518
1231
  youtube: "youtube",
519
1232
  tiktok: "tiktok",
520
1233
  pinterest: "pinterest",
@@ -563,6 +1276,24 @@ function stripTagsPlain(text) {
563
1276
  }
564
1277
  return out;
565
1278
  }
1279
+ /**
1280
+ * Rich text keeps the markup around a merge tag. Unwrap the BeeFree `<code>`
1281
+ * before decoding, so a token quoted inside `data-bee-code` is not decoded
1282
+ * in the attribute. A quote inside a token stays encoded in an attribute
1283
+ * and is decoded in a text node.
1284
+ */
1285
+ function importedHtml(html) {
1286
+ return restoreDoubledNbsp(decodeEntitiesInHtml(trimHighlightedTokenPadding(unwrapMergeTagCode(html))));
1287
+ }
1288
+ /**
1289
+ * A button label or table cell is plain text. Unwrap before stripping, so a
1290
+ * `>` inside a mergetag `<code>` attribute is not the end of the tag, and
1291
+ * strip before decoding, so a `&gt;` inside a token is not a stray `>`.
1292
+ * Decode references outside tokens too: the renderer escapes the whole label.
1293
+ */
1294
+ function importedPlainText(value) {
1295
+ return decodePlainTextEntities(stripTagsPlain(trimHighlightedTokenPadding(unwrapMergeTagCode(value))));
1296
+ }
566
1297
  function makeStyles(descriptor) {
567
1298
  const padding = extractPadding(descriptor.style);
568
1299
  const bg = parseColor(descriptor.style?.["background-color"]);
@@ -572,76 +1303,624 @@ function makeStyles(descriptor) {
572
1303
  };
573
1304
  }
574
1305
  /**
575
- * Apply BeeFree text styles as TipTap-compatible inline markup.
576
- * - text-align → added to each <p> tag's style attribute
577
- * - color, font-size, font-weight, font-family → wrapped in <span style="..."> inside each <p>
1306
+ * Pixels taken from `padding` on `sides`, at most `deficit`, in that order.
1307
+ * Nothing is taken past 0.
1308
+ */
1309
+ function paddingReturned(padding, deficit, sides) {
1310
+ let returned = 0;
1311
+ let remaining = deficit;
1312
+ for (const side of sides) {
1313
+ if (remaining <= 0) break;
1314
+ const have = padding[side];
1315
+ if (have <= 0) continue;
1316
+ const give = Math.min(remaining, have);
1317
+ returned += give;
1318
+ remaining -= give;
1319
+ }
1320
+ return returned;
1321
+ }
1322
+ /**
1323
+ * The room an image, a lone icon or a divider is measured in: the column
1324
+ * less the side padding the block keeps. A snapped column gives inner
1325
+ * padding back after the width is chosen. Those pixels are part of the room.
1326
+ * Column padding the block keeps, on either side, is not, whether or not any
1327
+ * padding comes back.
1328
+ */
1329
+ function measuredRoom(columnWidth, padding, giveBack) {
1330
+ if (columnWidth === void 0) return void 0;
1331
+ const room = columnWidth - padding.left - padding.right;
1332
+ if (!giveBack) return room;
1333
+ const returned = paddingReturned({
1334
+ left: padding.left + giveBack.columnPadding.left,
1335
+ right: padding.right + giveBack.columnPadding.right
1336
+ }, giveBack.deficit, giveBack.sides);
1337
+ return room - giveBack.columnPadding.left - giveBack.columnPadding.right + returned;
1338
+ }
1339
+ /**
1340
+ * A block BeeFree's text markup uses as a root. `ul` / `ol` carry the module's
1341
+ * alignment and line-height; their items carry the text styles.
1342
+ */
1343
+ const TEXT_ROOTS = /* @__PURE__ */ new Set([
1344
+ "p",
1345
+ "li",
1346
+ "div",
1347
+ "ul",
1348
+ "ol",
1349
+ "h1",
1350
+ "h2",
1351
+ "h3",
1352
+ "h4",
1353
+ "h5",
1354
+ "h6"
1355
+ ]);
1356
+ /**
1357
+ * BeeFree stores a text module's line-height as a percentage (`120%`, `200%`)
1358
+ * or a unitless number, and renders that — not the px value TinyMCE writes
1359
+ * into the blob. The paragraph editor stores the unitless ratio.
1360
+ */
1361
+ function moduleLineHeight(value) {
1362
+ if (!value) return void 0;
1363
+ const trimmed = value.trim();
1364
+ if (!trimmed || /^(inherit|initial|unset|revert)$/i.test(trimmed)) return void 0;
1365
+ const percent = /^(\d+(?:\.\d+)?)\s*%$/.exec(trimmed);
1366
+ if (percent) {
1367
+ const ratio = Number(percent[1]) / 100;
1368
+ if (!Number.isFinite(ratio) || ratio <= 0) return void 0;
1369
+ return String(Math.round(ratio * 1e3) / 1e3);
1370
+ }
1371
+ if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
1372
+ const ratio = Number(trimmed);
1373
+ return Number.isFinite(ratio) && ratio > 0 ? String(ratio) : void 0;
1374
+ }
1375
+ const length = /^(\d+(?:\.\d+)?)\s*(px|em|rem)$/i.exec(trimmed);
1376
+ if (length) return `${length[1]}${length[2].toLowerCase()}`;
1377
+ if (/^normal$/i.test(trimmed)) return "normal";
1378
+ }
1379
+ /** `0` and `normal` are the CSS initial value, so they add no declaration. */
1380
+ function moduleLetterSpacing(value) {
1381
+ if (!value) return void 0;
1382
+ const compact = value.trim().replace(/\s+/g, "");
1383
+ if (!compact || /^(normal|inherit|initial|unset|revert)$/i.test(compact)) return void 0;
1384
+ if (/^0+(?:\.0+)?(?:px|em|rem|%)?$/i.test(compact)) return void 0;
1385
+ return compact;
1386
+ }
1387
+ /**
1388
+ * The colour BeeFree paints on a text module's links: `linkColor` or
1389
+ * `link-color` on the module style, then the same keys on `computedStyle`.
1390
+ */
1391
+ function moduleLinkColor(content) {
1392
+ if (!content) return "";
1393
+ return parseColor(content.style?.linkColor) || parseColor(content.style?.["link-color"]) || parseColor(content.computedStyle?.linkColor) || parseColor(content.computedStyle?.["link-color"]) || "";
1394
+ }
1395
+ function attrWhitespaceStart(tag, nameStart) {
1396
+ let attrStart = nameStart;
1397
+ while (attrStart > 0 && /\s/.test(tag[attrStart - 1])) attrStart--;
1398
+ return attrStart;
1399
+ }
1400
+ /**
1401
+ * Exclusive end of the attribute. Whitespace after it belongs to the attribute
1402
+ * only when the tag ends there; a following attribute keeps its separator.
1403
+ */
1404
+ function styleAttrEnd(tag, end) {
1405
+ let i = end;
1406
+ while (i < tag.length && /\s/.test(tag[i])) i++;
1407
+ if (i >= tag.length || tag[i] === ">" || tag[i] === "/") return i;
1408
+ return end;
1409
+ }
1410
+ function styleLoc(tag, nameStart, valueStart, rawEnd, value, quote) {
1411
+ const attrEnd = styleAttrEnd(tag, rawEnd);
1412
+ return {
1413
+ attrStart: attrWhitespaceStart(tag, nameStart),
1414
+ attrEnd,
1415
+ valueStart,
1416
+ valueEnd: quote ? rawEnd - 1 : attrEnd,
1417
+ value,
1418
+ ...quote ? { quote } : {}
1419
+ };
1420
+ }
1421
+ /** The first `style` attribute on an open tag, ignoring `data-mce-style`. */
1422
+ function findStyleAttr(tag) {
1423
+ let i = 1;
1424
+ if (tag[i] === "/") i++;
1425
+ while (i < tag.length && /[a-z0-9]/i.test(tag[i])) i++;
1426
+ for (;;) {
1427
+ while (i < tag.length && /\s/.test(tag[i])) i++;
1428
+ if (i >= tag.length || tag[i] === ">" || tag[i] === "/") return void 0;
1429
+ const nameStart = i;
1430
+ while (i < tag.length && !/[\s=/>]/.test(tag[i])) i++;
1431
+ const name = tag.slice(nameStart, i).toLowerCase();
1432
+ const nameEnd = i;
1433
+ while (i < tag.length && /\s/.test(tag[i])) i++;
1434
+ if (i >= tag.length || tag[i] !== "=") {
1435
+ if (name === "style") return styleLoc(tag, nameStart, nameEnd, nameEnd, "");
1436
+ continue;
1437
+ }
1438
+ i++;
1439
+ while (i < tag.length && /\s/.test(tag[i])) i++;
1440
+ if (i >= tag.length) return void 0;
1441
+ const quote = tag[i];
1442
+ if (quote !== "\"" && quote !== "'") {
1443
+ const valueStart = i;
1444
+ while (i < tag.length && !/\s/.test(tag[i]) && tag[i] !== ">") i++;
1445
+ if (name === "style") return styleLoc(tag, nameStart, valueStart, i, decodePlainTextEntities(tag.slice(valueStart, i)));
1446
+ continue;
1447
+ }
1448
+ const valueStart = i + 1;
1449
+ const valueEnd = tag.indexOf(quote, valueStart);
1450
+ if (valueEnd === -1) return void 0;
1451
+ if (name === "style") return styleLoc(tag, nameStart, valueStart, valueEnd + 1, decodePlainTextEntities(tag.slice(valueStart, valueEnd)), quote);
1452
+ i = valueEnd + 1;
1453
+ }
1454
+ }
1455
+ /**
1456
+ * `additions` are appended when the declaration is absent. Names in `strip`
1457
+ * are removed first, so a module line-height replaces the blob's px value.
1458
+ * `undefined` means the attribute is empty and should be removed.
1459
+ */
1460
+ function mergeStyleValue(existing, additions, strip) {
1461
+ const kept = [];
1462
+ const present = /* @__PURE__ */ new Set();
1463
+ for (const part of existing.split(";")) {
1464
+ const trimmed = part.trim();
1465
+ if (!trimmed) continue;
1466
+ const colon = trimmed.indexOf(":");
1467
+ const name = (colon === -1 ? trimmed : trimmed.slice(0, colon)).trim().toLowerCase();
1468
+ present.add(name);
1469
+ if (strip.has(name)) continue;
1470
+ kept.push(trimmed);
1471
+ }
1472
+ for (const add of additions) if (strip.has(add.name) || !present.has(add.name)) kept.push(`${add.name}: ${add.value}`);
1473
+ return kept.length > 0 ? kept.join("; ") : void 0;
1474
+ }
1475
+ /**
1476
+ * Double quotes. `"` and a bare `&` are escaped, then `<` and `>`, so the
1477
+ * attribute stays one value. An entity already in the value is left as
1478
+ * written. {@link decodePlainTextEntities} reads the escapes back.
1479
+ */
1480
+ function quoteStyleValue(value) {
1481
+ return `"${value.replace(/&(?![A-Za-z][A-Za-z0-9]+;|#[0-9]+;|#x[0-9A-Fa-f]+;)/g, "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")}"`;
1482
+ }
1483
+ function rewriteOpenTag(openTag, additions, strip) {
1484
+ if (additions.length === 0 && strip.size === 0) return openTag;
1485
+ const loc = findStyleAttr(openTag);
1486
+ if (!loc) {
1487
+ if (additions.length === 0) return openTag;
1488
+ const value = additions.map((decl) => `${decl.name}: ${decl.value}`).join("; ");
1489
+ const at = openTag.endsWith("/>") ? openTag.length - 2 : openTag.length - 1;
1490
+ return `${openTag.slice(0, at)} style=${quoteStyleValue(value)}${openTag.slice(at)}`;
1491
+ }
1492
+ const merged = mergeStyleValue(loc.value, additions, strip);
1493
+ if (merged === void 0) return openTag.slice(0, loc.attrStart) + openTag.slice(loc.attrEnd);
1494
+ if (loc.quote && merged === loc.value) return openTag;
1495
+ return openTag.slice(0, loc.attrStart) + ` style=${quoteStyleValue(merged)}` + openTag.slice(loc.attrEnd);
1496
+ }
1497
+ function flattenChunks(chunks) {
1498
+ const out = [];
1499
+ const lists = [chunks];
1500
+ const index = [0];
1501
+ while (lists.length > 0) {
1502
+ const list = lists[lists.length - 1];
1503
+ const at = index[index.length - 1];
1504
+ if (at >= list.length) {
1505
+ lists.pop();
1506
+ index.pop();
1507
+ continue;
1508
+ }
1509
+ index[index.length - 1] = at + 1;
1510
+ const chunk = list[at];
1511
+ if (typeof chunk === "string") {
1512
+ if (chunk !== "") out.push(chunk);
1513
+ } else {
1514
+ lists.push(chunk);
1515
+ index.push(0);
1516
+ }
1517
+ }
1518
+ return out.join("");
1519
+ }
1520
+ function chunkText(chunks) {
1521
+ const bits = [];
1522
+ for (const chunk of chunks) if (typeof chunk === "string") bits.push(chunk);
1523
+ return bits.join("");
1524
+ }
1525
+ function listIndentPx(content) {
1526
+ const stated = content?.computedStyle?.liIndent?.trim();
1527
+ const match = stated ? /^(\d+(?:\.\d+)?)\s*px$/i.exec(stated) : null;
1528
+ if (!match) return 40;
1529
+ return Math.round(Number(match[1]));
1530
+ }
1531
+ function listBoxFor(content) {
1532
+ const indent = listIndentPx(content);
1533
+ return {
1534
+ outer: Math.round(indent / 2),
1535
+ nested: indent,
1536
+ rtl: content.style?.direction?.trim().toLowerCase() === "rtl"
1537
+ };
1538
+ }
1539
+ /**
1540
+ * Apply a BeeFree text module's style as TipTap-compatible inline markup.
1541
+ *
1542
+ * `text-align`, `line-height` and `font-size` go on each block root (`p`,
1543
+ * `li`, `h1`–`h6`, `div`, `ul`, `ol`). The line box uses that element's font
1544
+ * size, so a size that lives only on an inner span leaves the root at the
1545
+ * rendered text default of 14px. Color, weight, family, letter-spacing and
1546
+ * the same font size wrap the text of a root that holds no nested block. A
1547
+ * declaration the blob already sets stays; the module line-height replaces
1548
+ * one, because that is the value BeeFree renders.
1549
+ *
1550
+ * An `<a>` with no colour of its own takes `linkColor` inline, so it wins over
1551
+ * the document link colour. An anchor that already states a colour keeps it.
1552
+ *
1553
+ * `listBox` is set for a list module. It writes that box inline on `<ul>`,
1554
+ * `<ol>` and `<li>` — zero vertical margin, the indent padding, and
1555
+ * `list-style-type: revert` — so it beats the renderer's list spacing, which
1556
+ * juice would otherwise inline on top. A `margin` shorthand already on the
1557
+ * element stands, and so does a padding or marker it already sets. An item
1558
+ * margin of `0` is the shorthand: that is the property the renderer sets.
1559
+ *
1560
+ * A block with no text, break, image, or non-breaking space stays empty.
1561
+ * The renderer fills `<p></p>` with one line; a span inside it is content
1562
+ * and that line collapses.
1563
+ *
1564
+ * One forward scan. A regex over `[\s\S]*?</p>` is polynomial-ReDoS on
1565
+ * `<p>a<p>a<p>a…` with no closer; every cursor here only moves forward.
1566
+ * Unclosed roots stay nested. Their pieces are joined once at the root, so
1567
+ * a chain of them copies each character once.
578
1568
  */
579
- function inlineStylesToHtml(html, style, textColor) {
580
- const spanParts = [];
1569
+ function isWordChar(char) {
1570
+ if (char === void 0) return false;
1571
+ const code = char.charCodeAt(0);
1572
+ return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122 || char === "_";
1573
+ }
1574
+ /** `<br` or `<img` with a word boundary after the name, as `/<br\b/i` does. */
1575
+ function hasBreakOrImage(inner) {
1576
+ let i = 0;
1577
+ while (i < inner.length) {
1578
+ const at = inner.indexOf("<", i);
1579
+ if (at === -1) return false;
1580
+ if (namedTagAt(inner, at, "br") || namedTagAt(inner, at, "img")) return true;
1581
+ i = at + 1;
1582
+ }
1583
+ return false;
1584
+ }
1585
+ function namedTagAt(html, at, name) {
1586
+ if (at + 1 + name.length > html.length) return false;
1587
+ for (let i = 0; i < name.length; i++) {
1588
+ const char = html[at + 1 + i];
1589
+ const expect = name[i];
1590
+ if (char !== expect && char !== expect.toUpperCase()) return false;
1591
+ }
1592
+ return !isWordChar(html[at + 1 + name.length]);
1593
+ }
1594
+ /**
1595
+ * Drop each `<…>` that has at least one character before `>`. The result is
1596
+ * only an emptiness check: it is never written back into the markup. `<` with
1597
+ * no closer, and `<>`, stay.
1598
+ */
1599
+ function withoutTags(value) {
1600
+ const parts = [];
1601
+ let i = 0;
1602
+ let start = 0;
1603
+ while (i < value.length) {
1604
+ if (value[i] !== "<") {
1605
+ i++;
1606
+ continue;
1607
+ }
1608
+ let j = i + 1;
1609
+ while (j < value.length && value[j] !== ">") j++;
1610
+ if (j >= value.length || j === i + 1) {
1611
+ i++;
1612
+ continue;
1613
+ }
1614
+ if (start < i) parts.push(value.slice(start, i));
1615
+ i = j + 1;
1616
+ start = i;
1617
+ }
1618
+ if (parts.length === 0) return start === 0 ? value : value.slice(start);
1619
+ if (start < value.length) parts.push(value.slice(start));
1620
+ return parts.join("");
1621
+ }
1622
+ /** Text, a break, an image, or a non-breaking space inside a block. */
1623
+ function frameHasInk(inner) {
1624
+ if (hasBreakOrImage(inner)) return true;
1625
+ return withoutTags(inner).replace(/&nbsp;|&#160;|\u00a0/gi, "x").trim().length > 0;
1626
+ }
1627
+ function inlineStylesToHtml(html, style, textColor, linkColor, listBox) {
1628
+ const spanDecls = [];
581
1629
  const fontSize = parsePxValue(style["font-size"]);
582
- if (fontSize && fontSize !== 16) spanParts.push(`font-size: ${fontSize}px`);
1630
+ if (fontSize && fontSize !== 16) spanDecls.push({
1631
+ name: "font-size",
1632
+ value: `${fontSize}px`
1633
+ });
583
1634
  const color = parseColor(style.color);
584
- if (color && color !== textColor) spanParts.push(`color: ${color}`);
1635
+ if (color && color !== textColor) spanDecls.push({
1636
+ name: "color",
1637
+ value: color
1638
+ });
585
1639
  const fontWeight = style["font-weight"];
586
- if (fontWeight && fontWeight !== "normal" && fontWeight !== "400") spanParts.push(`font-weight: ${fontWeight}`);
1640
+ if (fontWeight && fontWeight !== "normal" && fontWeight !== "400") spanDecls.push({
1641
+ name: "font-weight",
1642
+ value: fontWeight
1643
+ });
587
1644
  const fontFamily = canonicalFontStack(style["font-family"]);
588
- if (fontFamily) spanParts.push(`font-family: ${fontFamily}`);
1645
+ if (fontFamily) spanDecls.push({
1646
+ name: "font-family",
1647
+ value: fontFamily
1648
+ });
1649
+ const letterSpacing = moduleLetterSpacing(style["letter-spacing"]);
1650
+ if (letterSpacing) spanDecls.push({
1651
+ name: "letter-spacing",
1652
+ value: letterSpacing
1653
+ });
1654
+ const blockDecls = [];
1655
+ if (fontSize > 0) blockDecls.push({
1656
+ name: "font-size",
1657
+ value: `${fontSize}px`
1658
+ });
589
1659
  const textAlign = style["text-align"];
590
- const pStyle = textAlign && textAlign !== "left" ? `text-align: ${textAlign}` : "";
591
- if (!pStyle && spanParts.length === 0) return html;
592
- const spanStyle = spanParts.join("; ");
593
- let result = html;
594
- if (pStyle) result = result.replace(/<p style="([^"]*)">/g, `<p style="$1; ${pStyle}">`).replaceAll("<p>", `<p style="${pStyle}">`);
595
- if (spanStyle) result = wrapParagraphInner(result, spanStyle);
596
- return result;
597
- }
598
- /**
599
- * Wrap the inner content of every `<p …>…</p>` with `<span style="…">…</span>`.
600
- *
601
- * Hand-rolled linear scanner instead of `/<p([^>]*)>([\s\S]*?)<\/p>/g` because
602
- * the regex is polynomial-ReDoS: the engine retries `[\s\S]*?<\/p>` at every
603
- * `<p` start, so inputs like `<p>a<p>a<p>a…` (no `</p>` ever) cost O(n²).
604
- * The scanner advances `i` monotonically via `indexOf`, keeping the work
605
- * strictly O(n).
606
- */
607
- function wrapParagraphInner(html, spanStyle) {
608
- let out = "";
1660
+ if (textAlign && textAlign !== "left") blockDecls.push({
1661
+ name: "text-align",
1662
+ value: textAlign
1663
+ });
1664
+ const lineHeight = moduleLineHeight(style["line-height"]);
1665
+ if (lineHeight) blockDecls.push({
1666
+ name: "line-height",
1667
+ value: lineHeight
1668
+ });
1669
+ const spanStyle = spanDecls.map((decl) => `${decl.name}: ${decl.value}`).join("; ");
1670
+ const strip = /* @__PURE__ */ new Set();
1671
+ if (lineHeight) strip.add("line-height");
1672
+ const anchorDecls = linkColor ? [{
1673
+ name: "color",
1674
+ value: linkColor
1675
+ }] : [];
1676
+ if (blockDecls.length === 0 && spanDecls.length === 0 && anchorDecls.length === 0 && !listBox) return html;
1677
+ const root = [];
1678
+ const stack = [];
1679
+ const parts = () => stack.length > 0 ? stack[stack.length - 1].parts : root;
1680
+ const tagAdditions = (name, kind) => {
1681
+ const decls = kind === "block" ? blockDecls : [...blockDecls, ...spanDecls];
1682
+ return name === "a" ? [...decls, ...anchorDecls] : decls;
1683
+ };
1684
+ const declNames = (openTag) => {
1685
+ const loc = findStyleAttr(openTag);
1686
+ const names = /* @__PURE__ */ new Set();
1687
+ if (!loc) return names;
1688
+ for (const part of loc.value.split(";")) {
1689
+ const trimmed = part.trim();
1690
+ if (!trimmed) continue;
1691
+ const colon = trimmed.indexOf(":");
1692
+ const name = (colon === -1 ? trimmed : trimmed.slice(0, colon)).trim().toLowerCase();
1693
+ if (name) names.add(name);
1694
+ }
1695
+ return names;
1696
+ };
1697
+ const listBoxDecls = (name, openTag, depth) => {
1698
+ if (!listBox) return [];
1699
+ const present = declNames(openTag);
1700
+ if (name === "li") return [...present].some((decl) => decl === "margin" || decl.startsWith("margin-")) ? [] : [{
1701
+ name: "margin",
1702
+ value: "0"
1703
+ }];
1704
+ if (name !== "ul" && name !== "ol") return [];
1705
+ const decls = [];
1706
+ if (!present.has("margin") && !present.has("margin-top")) decls.push({
1707
+ name: "margin-top",
1708
+ value: "0"
1709
+ });
1710
+ if (!present.has("margin") && !present.has("margin-bottom")) decls.push({
1711
+ name: "margin-bottom",
1712
+ value: "0"
1713
+ });
1714
+ const padding = listBox.rtl ? "padding-right" : "padding-left";
1715
+ if (!present.has("padding") && !present.has(padding)) {
1716
+ const px = depth === 1 ? listBox.outer : listBox.nested;
1717
+ decls.push({
1718
+ name: padding,
1719
+ value: `${px}px`
1720
+ });
1721
+ }
1722
+ if (!present.has("list-style") && !present.has("list-style-type")) decls.push({
1723
+ name: "list-style-type",
1724
+ value: "revert"
1725
+ });
1726
+ return decls;
1727
+ };
1728
+ const emitFrame = (frame, rawClose) => {
1729
+ const leaf = !frame.blockChild;
1730
+ const listRoot = frame.name === "ul" || frame.name === "ol";
1731
+ const wrap = leaf && !listRoot && spanStyle !== "";
1732
+ const parent = stack.length > 0 ? stack[stack.length - 1] : void 0;
1733
+ if (parent) parent.blockChild = true;
1734
+ const dest = parent ? parent.parts : root;
1735
+ const open = (kind) => rewriteOpenTag(frame.openTag, [...tagAdditions(frame.name, kind), ...listBoxDecls(frame.name, frame.openTag, frame.listDepth)], strip);
1736
+ const chunk = [];
1737
+ if (leaf && !listRoot && !frameHasInk(chunkText(frame.parts))) {
1738
+ chunk.push(open("block"));
1739
+ if (rawClose !== "") chunk.push(rawClose);
1740
+ } else if (wrap) {
1741
+ chunk.push(open("block"), `<span style=${quoteStyleValue(spanStyle)}>`, frame.parts, "</span>");
1742
+ if (rawClose !== "") chunk.push(rawClose);
1743
+ } else {
1744
+ const kind = !leaf && !listRoot && frame.name !== "div" ? "all" : "block";
1745
+ chunk.push(open(kind), frame.parts);
1746
+ if (rawClose !== "") chunk.push(rawClose);
1747
+ }
1748
+ dest.push(chunk);
1749
+ };
1750
+ const closeFrame = (name, rawClose) => {
1751
+ let idx = -1;
1752
+ for (let s = stack.length - 1; s >= 0; s--) if (stack[s].name === name) {
1753
+ idx = s;
1754
+ break;
1755
+ }
1756
+ if (idx === -1) {
1757
+ parts().push(rawClose);
1758
+ return;
1759
+ }
1760
+ while (stack.length - 1 > idx) emitFrame(stack.pop(), "");
1761
+ emitFrame(stack.pop(), rawClose);
1762
+ };
609
1763
  let i = 0;
610
1764
  while (i < html.length) {
611
- const open = html.indexOf("<p", i);
612
- if (open === -1) {
613
- out += html.substring(i);
1765
+ const lt = html.indexOf("<", i);
1766
+ if (lt === -1) {
1767
+ parts().push(html.slice(i));
614
1768
  break;
615
1769
  }
616
- const afterTagName = html[open + 2];
617
- if (afterTagName !== ">" && afterTagName !== " " && afterTagName !== " " && afterTagName !== "\n" && afterTagName !== "\r" && afterTagName !== "/") {
618
- out += html.substring(i, open + 2);
619
- i = open + 2;
1770
+ parts().push(html.slice(i, lt));
1771
+ if (html.startsWith("<!--", lt)) {
1772
+ const end = html.indexOf("-->", lt + 4);
1773
+ if (end === -1) {
1774
+ parts().push(html.slice(lt));
1775
+ break;
1776
+ }
1777
+ parts().push(html.slice(lt, end + 3));
1778
+ i = end + 3;
620
1779
  continue;
621
1780
  }
622
- const openEnd = html.indexOf(">", open + 2);
623
- if (openEnd === -1) {
624
- out += html.substring(i);
625
- break;
1781
+ const isClose = html[lt + 1] === "/";
1782
+ const nameStart = lt + (isClose ? 2 : 1);
1783
+ if (!/[a-z]/i.test(html[nameStart] ?? "")) {
1784
+ parts().push("<");
1785
+ i = lt + 1;
1786
+ continue;
626
1787
  }
627
- const closeStart = html.indexOf("</p>", openEnd + 1);
628
- if (closeStart === -1) {
629
- out += html.substring(i);
1788
+ let nameEnd = nameStart;
1789
+ while (nameEnd < html.length && /[a-z0-9]/i.test(html[nameEnd])) nameEnd++;
1790
+ const boundary = html[nameEnd];
1791
+ if (boundary !== void 0 && !/[\s>/]/.test(boundary)) {
1792
+ parts().push(html.slice(lt, nameEnd));
1793
+ i = nameEnd;
1794
+ continue;
1795
+ }
1796
+ const name = html.slice(nameStart, nameEnd).toLowerCase();
1797
+ if (isClose) {
1798
+ const end = html.indexOf(">", nameEnd);
1799
+ if (end === -1) {
1800
+ parts().push(html.slice(lt));
1801
+ break;
1802
+ }
1803
+ closeFrame(name, html.slice(lt, end + 1));
1804
+ i = end + 1;
1805
+ continue;
1806
+ }
1807
+ const end = findOpenTagEnd(html, nameEnd);
1808
+ if (end === -1) {
1809
+ parts().push(html.slice(lt));
630
1810
  break;
631
1811
  }
632
- const inner = html.substring(openEnd + 1, closeStart);
633
- out += html.substring(i, openEnd + 1);
634
- out += `<span style="${spanStyle}">${inner}</span></p>`;
635
- i = closeStart + 4;
1812
+ const openTag = html.slice(lt, end + 1);
1813
+ if (html[end - 1] === "/" || !TEXT_ROOTS.has(name)) {
1814
+ parts().push(rewriteOpenTag(openTag, name === "a" ? anchorDecls : [], strip));
1815
+ i = end + 1;
1816
+ continue;
1817
+ }
1818
+ const parentDepth = stack.length > 0 ? stack[stack.length - 1].listDepth : 0;
1819
+ stack.push({
1820
+ name,
1821
+ openTag,
1822
+ parts: [],
1823
+ blockChild: false,
1824
+ listDepth: name === "ul" || name === "ol" ? parentDepth + 1 : parentDepth
1825
+ });
1826
+ i = end + 1;
636
1827
  }
637
- return out;
1828
+ while (stack.length > 0) emitFrame(stack.pop(), "");
1829
+ return flattenChunks(root);
1830
+ }
1831
+ /**
1832
+ * A px length, rounded like {@link parsePxValue}, or `undefined` when it is
1833
+ * not one. Unitless `0` is `0`: CSS treats it as a length. A negative length
1834
+ * is not a gap.
1835
+ */
1836
+ function explicitPx(value) {
1837
+ const trimmed = value.trim();
1838
+ if (/^0+(?:\.0+)?$/.test(trimmed)) return 0;
1839
+ if (!/^\d+(?:\.\d+)?\s*px$/i.test(trimmed)) return void 0;
1840
+ return parsePxValue(trimmed);
1841
+ }
1842
+ /** The bottom length of a `margin` shorthand, when that side is a px length. */
1843
+ function shorthandMarginBottom(value) {
1844
+ const parts = value.trim().split(/\s+/);
1845
+ if (parts.length < 1 || parts.length > 4) return void 0;
1846
+ return explicitPx(parts.length <= 2 ? parts[0] : parts[2]);
1847
+ }
1848
+ /**
1849
+ * The px `margin-bottom` an opening tag states, after a `margin` shorthand
1850
+ * and a later `margin-bottom` the way CSS applies them. `undefined` when the
1851
+ * tag states none, or the winning value is not a px length.
1852
+ */
1853
+ function tagMarginBottom(tag) {
1854
+ const style = findStyleAttr(tag)?.value;
1855
+ if (style === void 0) return void 0;
1856
+ let gap;
1857
+ let set = false;
1858
+ for (const part of style.split(";")) {
1859
+ const colon = part.indexOf(":");
1860
+ if (colon === -1) continue;
1861
+ const prop = part.slice(0, colon).trim().toLowerCase();
1862
+ if (prop !== "margin" && prop !== "margin-bottom") continue;
1863
+ const value = part.slice(colon + 1).trim();
1864
+ const bottom = prop === "margin" ? shorthandMarginBottom(value) : explicitPx(value);
1865
+ if (bottom === void 0) {
1866
+ set = false;
1867
+ gap = void 0;
1868
+ } else {
1869
+ set = true;
1870
+ gap = bottom;
1871
+ }
1872
+ }
1873
+ return set ? gap : void 0;
1874
+ }
1875
+ function isParagraphOpen(html, open) {
1876
+ const name = html[open + 1];
1877
+ const next = html[open + 2];
1878
+ return (name === "p" || name === "P") && (next === ">" || next === " " || next === " " || next === "\n" || next === "\r" || next === "/");
1879
+ }
1880
+ /**
1881
+ * The gap between this block's paragraphs.
1882
+ *
1883
+ * BeeFree paragraphs sit at margin 0, so the gap is 0 unless a `<p>` states a
1884
+ * px `margin-bottom` or the bottom of a `margin` shorthand. The first such
1885
+ * length wins; one block has one gap.
1886
+ */
1887
+ function paragraphSpacingFromHtml(html) {
1888
+ let i = 0;
1889
+ while (i < html.length) {
1890
+ const open = html.indexOf("<", i);
1891
+ if (open === -1) return 0;
1892
+ if (!isParagraphOpen(html, open)) {
1893
+ i = open + 1;
1894
+ continue;
1895
+ }
1896
+ const openEnd = html.indexOf(">", open + 2);
1897
+ if (openEnd === -1) return 0;
1898
+ const gap = tagMarginBottom(html.slice(open, openEnd + 1));
1899
+ if (gap !== void 0) return gap;
1900
+ i = openEnd + 1;
1901
+ }
1902
+ return 0;
1903
+ }
1904
+ /**
1905
+ * A paragraph module's gap, in px. BeeFree stores it on
1906
+ * `computedStyle.paragraphSpacing` and renders it as `margin-bottom` on every
1907
+ * paragraph but the last. That wins over a margin written into the HTML. A
1908
+ * missing or non-px value falls back to the HTML.
1909
+ */
1910
+ function paragraphModuleSpacing(content, html) {
1911
+ const stated = content?.computedStyle?.paragraphSpacing?.trim();
1912
+ const match = stated ? /^(\d+(?:\.\d+)?)\s*px$/i.exec(stated) : null;
1913
+ if (!match) return paragraphSpacingFromHtml(html);
1914
+ return Math.round(Number(match[1]));
638
1915
  }
639
1916
  function convertText(descriptor, textColor) {
640
1917
  const textContent = descriptor.text ?? descriptor.paragraph ?? descriptor.list;
641
1918
  const html = textContent?.html ?? "";
642
1919
  const style = textContent?.style ?? {};
1920
+ const listBox = textContent !== void 0 && textContent === descriptor.list ? listBoxFor(textContent) : void 0;
643
1921
  return createParagraphBlock({
644
- content: inlineStylesToHtml(html, style, textColor),
1922
+ content: importedHtml(inlineStylesToHtml(html, style, textColor, moduleLinkColor(textContent), listBox)),
1923
+ paragraphSpacing: paragraphModuleSpacing(textContent, html),
645
1924
  styles: makeStyles(descriptor)
646
1925
  });
647
1926
  }
@@ -653,21 +1932,113 @@ function parseHeadingLevel(tag) {
653
1932
  }
654
1933
  return 2;
655
1934
  }
1935
+ const TITLE_LEVELS = [
1936
+ 1,
1937
+ 2,
1938
+ 3,
1939
+ 4
1940
+ ];
1941
+ /**
1942
+ * The title level whose renderer size is nearest `px`.
1943
+ *
1944
+ * Sizes are `HEADING_LEVEL_FONT_SIZE` in `@templatical/types`, the table
1945
+ * `renderTitle` writes onto the title's `mj-text`. That size is the line
1946
+ * box. A tie stays on the level nearer `source`.
1947
+ */
1948
+ function headingLevelForSize(px, source) {
1949
+ let best = source;
1950
+ let bestSize = Infinity;
1951
+ let bestSource = Infinity;
1952
+ for (const level of TITLE_LEVELS) {
1953
+ const size = Math.abs(HEADING_LEVEL_FONT_SIZE[level] - px);
1954
+ const toward = Math.abs(level - source);
1955
+ if (size < bestSize || size === bestSize && toward < bestSource) {
1956
+ best = level;
1957
+ bestSize = size;
1958
+ bestSource = toward;
1959
+ }
1960
+ }
1961
+ return best;
1962
+ }
1963
+ /**
1964
+ * Set when the chosen level's size is more than 1px off BeeFree's font size,
1965
+ * so the line box is not the source size. Names the source level when the
1966
+ * chosen level is a different one.
1967
+ */
1968
+ function headingLevelNote(px, source, chosen) {
1969
+ if (Math.abs(HEADING_LEVEL_FONT_SIZE[chosen] - px) <= 1) return void 0;
1970
+ return `h${chosen} chosen for ${px}px${chosen === source ? "" : ` (source h${source})`}; line box differs`;
1971
+ }
1972
+ /** `text-align` inside a CSS declaration list, when it is left, center or right. */
1973
+ function alignInStyle(css) {
1974
+ if (!css) return void 0;
1975
+ return /(?:^|;)\s*text-align\s*:\s*(left|center|right)\b/i.exec(css)?.[1].toLowerCase();
1976
+ }
1977
+ /**
1978
+ * Alignment on a heading tag BeeFree wrapped the text in
1979
+ * (`<h1 style="text-align: center">`). The tag is stripped before the text
1980
+ * is stored, so the alignment has to be read first.
1981
+ */
1982
+ function headingTagCss(text) {
1983
+ const open = /^<h\d\b([^>]*)>/i.exec(text.trim());
1984
+ if (!open) return void 0;
1985
+ const quoted = /\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(open[1]);
1986
+ return quoted?.[1] ?? quoted?.[2];
1987
+ }
1988
+ /** One declaration from a CSS list, or `undefined` when it is absent. */
1989
+ function cssDecl(css, name) {
1990
+ if (!css) return void 0;
1991
+ return new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*([^;]+)`, "i").exec(css)?.[1]?.trim() || void 0;
1992
+ }
1993
+ /**
1994
+ * The heading style wins over the same declaration on the heading tag.
1995
+ * The tag is stripped before the text is stored.
1996
+ */
1997
+ function headingSourceDecl(style, tagCss, name) {
1998
+ return style[name]?.trim() || cssDecl(tagCss, name);
1999
+ }
2000
+ /**
2001
+ * Typography the title block cannot store. The renderer sizes a title from
2002
+ * its level (an `h1` is 36px), paints it bold and sets line-height 1.3, so
2003
+ * the heading's own values go on the text and override those.
2004
+ * `letter-spacing` of `0` or `normal` adds nothing.
2005
+ */
2006
+ function headingTypographyStyle(style, tagCss) {
2007
+ const decls = [];
2008
+ const fontSize = parsePxValue(headingSourceDecl(style, tagCss, "font-size"));
2009
+ if (fontSize > 0) decls.push(`font-size: ${fontSize}px`);
2010
+ const lineHeight = moduleLineHeight(headingSourceDecl(style, tagCss, "line-height"));
2011
+ if (lineHeight) decls.push(`line-height: ${lineHeight}`);
2012
+ const weight = headingSourceDecl(style, tagCss, "font-weight");
2013
+ if (weight && !/^(inherit|initial|unset|revert)$/i.test(weight)) decls.push(`font-weight: ${weight}`);
2014
+ const letterSpacing = moduleLetterSpacing(headingSourceDecl(style, tagCss, "letter-spacing"));
2015
+ if (letterSpacing) decls.push(`letter-spacing: ${letterSpacing}`);
2016
+ return decls.length > 0 ? decls.join("; ") : void 0;
2017
+ }
656
2018
  function convertHeading(descriptor, textColor) {
657
2019
  const heading = descriptor.heading;
658
- if (!heading) return convertText(descriptor, textColor);
2020
+ if (!heading) return { block: convertText(descriptor, textColor) };
659
2021
  const style = heading.style ?? {};
660
2022
  const tag = heading.title ?? "h2";
661
2023
  const text = heading.text ?? "";
662
2024
  const content = text.startsWith("<") ? text.replace(/^<h\d[^>]*>|<\/h\d>$/gi, "") : text;
663
- return createTitleBlock({
664
- content: content ? `<p>${content}</p>` : "<p></p>",
665
- level: parseHeadingLevel(tag),
666
- color: parseColor(style.color) || void 0,
667
- textAlign: toAlign(style["text-align"]),
668
- fontFamily: canonicalFontStack(style["font-family"]) || void 0,
669
- styles: makeStyles(descriptor)
670
- });
2025
+ const tagCss = headingTagCss(text);
2026
+ const typography = headingTypographyStyle(style, tagCss);
2027
+ const inner = typography ? `<span style=${quoteStyleValue(typography)}>${content}</span>` : content;
2028
+ const source = parseHeadingLevel(tag);
2029
+ const px = parsePxValue(headingSourceDecl(style, tagCss, "font-size"));
2030
+ const level = px > 0 ? headingLevelForSize(px, source) : source;
2031
+ return {
2032
+ block: createTitleBlock({
2033
+ content: importedHtml(content || typography ? `<p>${inner}</p>` : "<p></p>"),
2034
+ level,
2035
+ color: parseColor(style.color) || void 0,
2036
+ textAlign: toAlign(style["text-align"] ?? alignInStyle(tagCss) ?? descriptor.style?.["text-align"]),
2037
+ fontFamily: canonicalFontStack(style["font-family"]) || void 0,
2038
+ styles: makeStyles(descriptor)
2039
+ }),
2040
+ note: px > 0 ? headingLevelNote(px, source, level) : void 0
2041
+ };
671
2042
  }
672
2043
  /**
673
2044
  * The classes BeeFree records in `computedStyle.class` for an image: its
@@ -692,9 +2063,9 @@ function computedPx(value) {
692
2063
  * column. `image.width` and `image.height` are the file's own size, the
693
2064
  * fallback for an export without `computedStyle`. No width, or one that
694
2065
  * reaches `room` (the column less the image's side padding), is `"full"`.
695
- * Older exports class an image that fills its column `fullwidth`, and its
696
- * computed width leaves out the column padding the import drops, so the class
697
- * decides. A computed percentage under 100% becomes px of `room`, with a note:
2066
+ * Older exports class an image that fills its column `fullwidth`. Its computed
2067
+ * width leaves out the column padding, so the class decides. A computed
2068
+ * percentage under 100% becomes px of `room`, with a note:
698
2069
  * px keeps its size on a phone, where the percentage would shrink.
699
2070
  */
700
2071
  function resolveImageWidth(descriptor, room) {
@@ -721,14 +2092,14 @@ function shownWidth(shown, room) {
721
2092
  if (shown <= 0) return "full";
722
2093
  return room !== void 0 && shown >= room ? "full" : shown;
723
2094
  }
724
- function convertImage(descriptor, columnWidth) {
2095
+ function convertImage(descriptor, columnWidth, paddingReturn) {
725
2096
  const image = descriptor.image;
726
2097
  const styles = makeStyles(descriptor);
727
2098
  if (!image) return {
728
2099
  block: createImageBlock({ styles }),
729
2100
  notes: []
730
2101
  };
731
- const room = columnWidth === void 0 ? void 0 : columnWidth - styles.padding.left - styles.padding.right;
2102
+ const room = measuredRoom(columnWidth, styles.padding, paddingReturn);
732
2103
  const { width, note } = resolveImageWidth(descriptor, room);
733
2104
  const fileWidth = parsePxValue(image.width);
734
2105
  const fileHeight = parsePxValue(image.height);
@@ -743,8 +2114,8 @@ function convertImage(descriptor, columnWidth) {
743
2114
  ].find((side) => classes.includes(side));
744
2115
  return {
745
2116
  block: createImageBlock({
746
- src: image.src || "",
747
- alt: image.alt || "",
2117
+ src: decodeEntitiesInTokens(image.src || ""),
2118
+ alt: decodeEntitiesInTokens(image.alt || ""),
748
2119
  width,
749
2120
  ...fileWidth > 0 && fileHeight > 0 ? {
750
2121
  naturalWidth: fileWidth,
@@ -752,34 +2123,199 @@ function convertImage(descriptor, columnWidth) {
752
2123
  } : {},
753
2124
  borderRadius: parseImageBorderRadius(image.style?.["border-radius"], stated ? shownWidth : void 0, shownHeight),
754
2125
  align: toAlign(image.style?.["text-align"] ?? classAlign, "center"),
755
- linkUrl: image.href || void 0,
2126
+ linkUrl: image.href ? decodeEntitiesInTokens(image.href) : void 0,
756
2127
  styles
757
2128
  }),
758
2129
  notes: note ? [note] : []
759
2130
  };
760
2131
  }
2132
+ /**
2133
+ * A button label's line-height in px. BeeFree writes a percentage of the font
2134
+ * size (`200%`), a unitless ratio (`2`) or a px length. Anything else is
2135
+ * unread, and the padding stays as BeeFree wrote it.
2136
+ */
2137
+ function buttonLineHeightPx(value, fontSize) {
2138
+ if (!value || fontSize <= 0) return void 0;
2139
+ const trimmed = value.trim();
2140
+ const percent = /^(\d+(?:\.\d+)?)\s*%$/.exec(trimmed);
2141
+ if (percent) {
2142
+ const ratio = Number(percent[1]) / 100;
2143
+ return Number.isFinite(ratio) && ratio > 0 ? fontSize * ratio : void 0;
2144
+ }
2145
+ if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
2146
+ const ratio = Number(trimmed);
2147
+ return Number.isFinite(ratio) && ratio > 0 ? fontSize * ratio : void 0;
2148
+ }
2149
+ const px = /^(\d+(?:\.\d+)?)\s*px$/i.exec(trimmed);
2150
+ if (!px) return void 0;
2151
+ const length = Number(px[1]);
2152
+ return Number.isFinite(length) && length > 0 ? length : void 0;
2153
+ }
2154
+ /**
2155
+ * How many px to add to the button's top and bottom padding so the label's
2156
+ * line box matches BeeFree. The rendered label is 1.2× the font size, so a
2157
+ * taller line-height is split across the two sides. The result is an integer
2158
+ * and never negative: a line-height at or under 1.2 adds nothing.
2159
+ */
2160
+ function lineHeightPadding(lineHeightPx, fontSize) {
2161
+ const extra = (lineHeightPx - 1.2 * fontSize) / 2;
2162
+ if (!Number.isFinite(extra) || extra <= 0) return 0;
2163
+ return Math.round(extra);
2164
+ }
2165
+ /**
2166
+ * A CSS font-weight as a number. `normal` is 400. `bold` and `bolder` are
2167
+ * 700 — `bolder` is relative, and with no parent weight the absolute step
2168
+ * it names is bold. Anything else unreadable is dropped.
2169
+ */
2170
+ function cssFontWeightNumber(value) {
2171
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
2172
+ if (typeof value !== "string") return void 0;
2173
+ const trimmed = value.trim().toLowerCase();
2174
+ if (!trimmed) return void 0;
2175
+ if (trimmed === "normal") return 400;
2176
+ if (trimmed === "bold" || trimmed === "bolder") return 700;
2177
+ if (!/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(trimmed)) return void 0;
2178
+ const numeric = Number(trimmed);
2179
+ return Number.isFinite(numeric) ? numeric : void 0;
2180
+ }
2181
+ /**
2182
+ * The nearest of 400, 500 and 700. The stops are lightest first and a tie
2183
+ * keeps the lighter one, so 450 is regular and 600 is medium.
2184
+ */
2185
+ function nearestButtonWeight(weight) {
2186
+ const stops = [
2187
+ ["regular", 400],
2188
+ ["medium", 500],
2189
+ ["bold", 700]
2190
+ ];
2191
+ let best = "regular";
2192
+ let bestDistance = Infinity;
2193
+ for (const [name, stop] of stops) {
2194
+ const distance = Math.abs(weight - stop);
2195
+ if (distance < bestDistance) {
2196
+ best = name;
2197
+ bestDistance = distance;
2198
+ }
2199
+ }
2200
+ return best;
2201
+ }
2202
+ /**
2203
+ * BeeFree's weight as the button field. Bold is what an omitted field
2204
+ * renders, so that step is left unset. An unreadable weight is dropped.
2205
+ */
2206
+ function buttonFontWeight(value) {
2207
+ const numeric = cssFontWeightNumber(value);
2208
+ if (numeric === void 0) return void 0;
2209
+ const mapped = nearestButtonWeight(numeric);
2210
+ return mapped === "bold" ? void 0 : mapped;
2211
+ }
2212
+ /**
2213
+ * A letter-spacing worth naming. `0` and `normal` are the CSS initial value,
2214
+ * so they are not a difference. CSS-wide keywords are unread the same way.
2215
+ */
2216
+ function uncarriedLetterSpacing(value) {
2217
+ if (!value) return void 0;
2218
+ const trimmed = value.trim();
2219
+ if (!trimmed) return void 0;
2220
+ const lower = trimmed.toLowerCase();
2221
+ if (lower === "normal" || lower === "0" || /^(?:inherit|initial|unset|revert)$/.test(lower) || /^0+(?:\.0+)?px$/.test(lower)) return;
2222
+ return trimmed;
2223
+ }
2224
+ /**
2225
+ * `100%` fills the column. Any other percentage has no field to land in, so
2226
+ * the button sizes to its label and the caller names that width. A px width
2227
+ * is that many pixels. `auto` and anything unreadable leave the width unset.
2228
+ */
2229
+ function buttonWidth(value) {
2230
+ if (!value) return {};
2231
+ const raw = value.trim();
2232
+ if (!raw || /^auto$/i.test(raw)) return {};
2233
+ const percent = /^(\d+(?:\.\d+)?)\s*%$/.exec(raw);
2234
+ if (percent) {
2235
+ if (Number(percent[1]) === 100) return { width: "full" };
2236
+ return { uncarried: `width ${raw}` };
2237
+ }
2238
+ const px = /^(\d+(?:\.\d+)?)\s*px$/i.exec(raw);
2239
+ if (!px) return {};
2240
+ const width = Math.round(Number(px[1]));
2241
+ return width > 0 ? { width } : {};
2242
+ }
2243
+ /**
2244
+ * One note naming what this button does not carry: a letter-spacing, and a
2245
+ * percentage width other than 100%. Empty when nothing was left out. Weight
2246
+ * is mapped, so it is not named here.
2247
+ */
2248
+ function buttonApproximationNote(style, widthUncarried) {
2249
+ const parts = [];
2250
+ const spacing = uncarriedLetterSpacing(style["letter-spacing"]);
2251
+ if (spacing) parts.push(`letter spacing ${spacing}`);
2252
+ if (widthUncarried) parts.push(widthUncarried);
2253
+ if (parts.length === 0) return void 0;
2254
+ const verb = parts.length === 1 ? "is" : "are";
2255
+ return `${parts.join(", ")} ${verb} not carried`;
2256
+ }
2257
+ function toButtonBorderStyle(value) {
2258
+ if (value === "dashed" || value === "dotted" || value === "solid") return value;
2259
+ return "solid";
2260
+ }
2261
+ /**
2262
+ * The button's border. A side that paints nothing has width 0. `undefined`
2263
+ * when no side is drawn. A style other than solid, dashed or dotted is solid.
2264
+ */
2265
+ function buttonBorder(style) {
2266
+ let drawn = false;
2267
+ const border = {};
2268
+ for (const side of BORDER_SIDES) {
2269
+ const raw = style[`border-${side}`];
2270
+ const parsed = raw ? parseBorderTop(raw) : void 0;
2271
+ const paints = parsed !== void 0 && parsed.drawn && parsed.width > 0;
2272
+ if (paints) drawn = true;
2273
+ border[side] = {
2274
+ width: paints ? parsed.width : 0,
2275
+ style: toButtonBorderStyle(parsed?.style),
2276
+ color: parsed?.color || "#000000"
2277
+ };
2278
+ }
2279
+ return drawn ? border : void 0;
2280
+ }
761
2281
  function convertButton(descriptor) {
762
2282
  const button = descriptor.button;
763
- if (!button) return createButtonBlock({ styles: makeStyles(descriptor) });
2283
+ if (!button) return {
2284
+ block: createButtonBlock({ styles: makeStyles(descriptor) }),
2285
+ notes: []
2286
+ };
764
2287
  const style = button.style ?? {};
765
- const label = button.label ? stripTagsPlain(button.label) : "Button";
766
- return createButtonBlock({
767
- text: label,
768
- url: button.href || "#",
769
- backgroundColor: parseColor(style["background-color"]) || "#4f46e5",
770
- textColor: parseColor(style.color) || "#ffffff",
771
- borderRadius: parsePxValue(style["border-radius"]),
772
- fontSize: parsePxValue(style["font-size"]) || 16,
773
- fontFamily: canonicalFontStack(style["font-family"]) || void 0,
774
- align: toAlign(style["text-align"], "center"),
775
- buttonPadding: {
776
- top: parsePxValue(style["padding-top"]) || 12,
777
- right: parsePxValue(style["padding-right"]) || 24,
778
- bottom: parsePxValue(style["padding-bottom"]) || 12,
779
- left: parsePxValue(style["padding-left"]) || 24
780
- },
781
- styles: makeStyles(descriptor)
782
- });
2288
+ const label = button.label ? importedPlainText(button.label) : "Button";
2289
+ const fontSize = parsePxValue(style["font-size"]) || 16;
2290
+ const lineHeightPx = buttonLineHeightPx(style["line-height"], fontSize);
2291
+ const linePad = lineHeightPx === void 0 ? 0 : lineHeightPadding(lineHeightPx, fontSize);
2292
+ const { width, uncarried: widthUncarried } = buttonWidth(style.width);
2293
+ const border = buttonBorder(style);
2294
+ const fontWeight = buttonFontWeight(style["font-weight"]);
2295
+ const note = buttonApproximationNote(style, widthUncarried);
2296
+ return {
2297
+ block: createButtonBlock({
2298
+ text: label,
2299
+ url: button.href ? decodeEntitiesInTokens(button.href) : "#",
2300
+ backgroundColor: parseColor(style["background-color"]) || "#4f46e5",
2301
+ textColor: parseColor(style.color) || "#ffffff",
2302
+ borderRadius: parsePxValue(style["border-radius"]),
2303
+ fontSize,
2304
+ fontFamily: canonicalFontStack(style["font-family"]) || void 0,
2305
+ ...fontWeight ? { fontWeight } : {},
2306
+ align: toAlign(descriptor.style?.["text-align"] ?? style["text-align"], "center"),
2307
+ ...width !== void 0 ? { width } : {},
2308
+ ...border ? { border } : {},
2309
+ buttonPadding: {
2310
+ top: (parsePxValue(style["padding-top"]) || 12) + linePad,
2311
+ right: parsePxValue(style["padding-right"]) || 24,
2312
+ bottom: (parsePxValue(style["padding-bottom"]) || 12) + linePad,
2313
+ left: parsePxValue(style["padding-left"]) || 24
2314
+ },
2315
+ styles: makeStyles(descriptor)
2316
+ }),
2317
+ notes: note ? [note] : []
2318
+ };
783
2319
  }
784
2320
  /**
785
2321
  * `"full"` when the divider spans its column: no width, `100%`, or a px width
@@ -804,12 +2340,12 @@ function resolveDividerWidth(value, contentWidth, notes) {
804
2340
  if (px !== Math.round(parsed.value)) notes.push(`Divider width ${raw} was clamped to 0px.`);
805
2341
  return contentWidth !== void 0 && px >= contentWidth ? "full" : px;
806
2342
  }
807
- function convertDivider(descriptor, columnWidth) {
2343
+ function convertDivider(descriptor, columnWidth, paddingReturn) {
808
2344
  const style = descriptor.divider?.style ?? {};
809
2345
  const border = parseBorderTop(style["border-top"]);
810
2346
  const styles = makeStyles(descriptor);
811
2347
  const notes = [];
812
- const contentWidth = columnWidth === void 0 ? void 0 : columnWidth - styles.padding.left - styles.padding.right;
2348
+ const contentWidth = measuredRoom(columnWidth, styles.padding, paddingReturn);
813
2349
  const width = resolveDividerWidth(style.width, contentWidth, notes);
814
2350
  const align = dividerAlign(descriptor);
815
2351
  if (width !== "full" && (align === "left" || align === "right")) notes.push(`BeeFree aligns this divider ${align}; Templatical centres every divider.`);
@@ -872,31 +2408,378 @@ function convertSpacer(descriptor) {
872
2408
  function convertHtml(descriptor) {
873
2409
  const html = descriptor.html?.html ?? "";
874
2410
  return createHtmlBlock({
875
- content: html,
2411
+ content: importedHtml(html),
876
2412
  styles: makeStyles(descriptor)
877
2413
  });
878
2414
  }
2415
+ /** `"E-Mail"` and `"YouTube"` are network names; a blank id is not one. */
2416
+ function socialKey(value) {
2417
+ return (value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "");
2418
+ }
2419
+ function platformFromKey(value) {
2420
+ const key = socialKey(value);
2421
+ return key ? SOCIAL_PLATFORM_MAP[key] : void 0;
2422
+ }
2423
+ const SOCIAL_HOSTS = [
2424
+ ["youtube.com", "youtube"],
2425
+ ["youtu.be", "youtube"],
2426
+ ["facebook.com", "facebook"],
2427
+ ["fb.com", "facebook"],
2428
+ ["instagram.com", "instagram"],
2429
+ ["linkedin.com", "linkedin"],
2430
+ ["xing.com", "xing"],
2431
+ ["twitter.com", "twitter"],
2432
+ ["x.com", "twitter"],
2433
+ ["tiktok.com", "tiktok"],
2434
+ ["pinterest.com", "pinterest"],
2435
+ ["pin.it", "pinterest"],
2436
+ ["whatsapp.com", "whatsapp"],
2437
+ ["wa.me", "whatsapp"],
2438
+ ["t.me", "telegram"],
2439
+ ["telegram.me", "telegram"],
2440
+ ["telegram.org", "telegram"],
2441
+ ["discord.com", "discord"],
2442
+ ["discord.gg", "discord"],
2443
+ ["snapchat.com", "snapchat"],
2444
+ ["reddit.com", "reddit"],
2445
+ ["github.com", "github"],
2446
+ ["dribbble.com", "dribbble"],
2447
+ ["behance.net", "behance"]
2448
+ ];
2449
+ /** The link's host when the icon's id and name are not a network. */
2450
+ function platformFromHref(href) {
2451
+ const raw = (href ?? "").trim();
2452
+ if (!raw) return void 0;
2453
+ if (/^mailto:/i.test(raw)) return "email";
2454
+ let host;
2455
+ try {
2456
+ host = new URL(raw).hostname.toLowerCase();
2457
+ } catch {
2458
+ return;
2459
+ }
2460
+ if (host.startsWith("www.")) host = host.slice(4);
2461
+ for (const [domain, platform] of SOCIAL_HOSTS) if (host === domain || host.endsWith(`.${domain}`)) return platform;
2462
+ }
2463
+ /**
2464
+ * BeeFree stores a custom icon's id as a UUID and the network in `name`, or
2465
+ * the other way around. A blank id is not a name. The link's host is the
2466
+ * last place the network is written.
2467
+ */
2468
+ function socialPlatform(icon) {
2469
+ return platformFromKey(icon.id) ?? platformFromKey(icon.name) ?? platformFromKey(icon.image?.alt) ?? platformFromKey(icon.image?.title) ?? platformFromHref(icon.image?.href);
2470
+ }
2471
+ function isUuid(value) {
2472
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value.trim());
2473
+ }
2474
+ function positivePx(value) {
2475
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return Math.round(value);
2476
+ if (typeof value === "string") return parsePxValue(value);
2477
+ return 0;
2478
+ }
2479
+ /** A name to put in the note: the alt text, or a name that is not a UUID. */
2480
+ function networkLabel(icon, href) {
2481
+ const alt = icon.image?.alt?.trim();
2482
+ if (alt) return alt;
2483
+ const title = icon.image?.title?.trim();
2484
+ if (title) return title;
2485
+ const name = icon.name?.trim();
2486
+ if (name && !isUuid(name)) return name;
2487
+ const id = icon.id?.trim();
2488
+ if (id && !isUuid(id)) return id;
2489
+ try {
2490
+ const host = new URL(href).hostname.replace(/^www\./, "");
2491
+ if (host) return host;
2492
+ } catch {}
2493
+ return "custom";
2494
+ }
2495
+ function safeCopiedUrl(value, kind, warnings) {
2496
+ if (!value) return "";
2497
+ if (isSafeHtmlUrl(value, kind)) return value;
2498
+ warnings.push(kind === "src" ? `Dropped the unsafe image "${value}" on a social icon.` : `Dropped the unsafe link "${value}" on a social icon.`);
2499
+ return "";
2500
+ }
2501
+ /**
2502
+ * A network Templatical does not draw. The file and the link stay, as an
2503
+ * image when there is a file and as a link when there is not.
2504
+ */
2505
+ function unknownSocialBlock(descriptor, icon, warnings) {
2506
+ const href = safeCopiedUrl(decodeEntitiesInTokens(icon.image?.href ?? ""), "href", warnings);
2507
+ const src = safeCopiedUrl(decodeEntitiesInTokens(icon.image?.src ?? ""), "src", warnings);
2508
+ const label = networkLabel(icon, href || icon.image?.href || "");
2509
+ if (!src && !href) {
2510
+ const note = `Social network "${label}" has no Templatical icon and no image or link to keep.`;
2511
+ warnings.push(note);
2512
+ return {
2513
+ block: createHtmlBlock({
2514
+ content: "",
2515
+ styles: makeStyles(descriptor)
2516
+ }),
2517
+ note
2518
+ };
2519
+ }
2520
+ const asLink = !src;
2521
+ const note = `Social network "${label}" has no Templatical icon; imported as ${asLink ? "a link" : "an image"}.`;
2522
+ warnings.push(note);
2523
+ if (asLink) return {
2524
+ block: createHtmlBlock({
2525
+ content: decodeEntitiesInHtml(`<a href="${escapeHtml(href)}">${escapeHtml(decodeEntitiesInTokens(label))}</a>`),
2526
+ styles: makeStyles(descriptor)
2527
+ }),
2528
+ note
2529
+ };
2530
+ const fileWidth = positivePx(icon.image?.width);
2531
+ const fileHeight = positivePx(icon.image?.height);
2532
+ const shown = positivePx(descriptor.computedStyle?.iconsDefaultWidth) || fileWidth || 32;
2533
+ return {
2534
+ block: createImageBlock({
2535
+ src,
2536
+ alt: decodeEntitiesInTokens(icon.image?.alt ?? ""),
2537
+ width: shown,
2538
+ ...fileWidth > 0 && fileHeight > 0 ? {
2539
+ naturalWidth: fileWidth,
2540
+ naturalHeight: fileHeight
2541
+ } : {},
2542
+ align: toAlign(descriptor.style?.["text-align"], "center"),
2543
+ linkUrl: href || void 0,
2544
+ ...icon.image?.target === "_blank" ? { linkOpenInNewTab: true } : {},
2545
+ styles: makeStyles(descriptor)
2546
+ }),
2547
+ note
2548
+ };
2549
+ }
2550
+ /**
2551
+ * BeeFree names an icon set in the file URL
2552
+ * (`…/social-networks-icon-sets/circle-dark-gray/facebook.png`).
2553
+ * A gray set uses the dark tone: the block's monochrome dark is `#1f1f1f`,
2554
+ * the nearest circle that is not a brand-coloured solid. A set whose shape
2555
+ * or tone has no match is absent, and the block keeps solid brand icons.
2556
+ */
2557
+ const SOCIAL_ICON_SETS = {
2558
+ "circle-color": { iconStyle: "circle" },
2559
+ "circle-colored": { iconStyle: "circle" },
2560
+ "circle-colour": { iconStyle: "circle" },
2561
+ "circle-black": {
2562
+ iconStyle: "circle",
2563
+ iconTone: "dark"
2564
+ },
2565
+ "circle-dark": {
2566
+ iconStyle: "circle",
2567
+ iconTone: "dark"
2568
+ },
2569
+ "circle-dark-gray": {
2570
+ iconStyle: "circle",
2571
+ iconTone: "dark"
2572
+ },
2573
+ "circle-dark-grey": {
2574
+ iconStyle: "circle",
2575
+ iconTone: "dark"
2576
+ },
2577
+ "circle-gray": {
2578
+ iconStyle: "circle",
2579
+ iconTone: "dark"
2580
+ },
2581
+ "circle-grey": {
2582
+ iconStyle: "circle",
2583
+ iconTone: "dark"
2584
+ },
2585
+ "circle-white": {
2586
+ iconStyle: "circle",
2587
+ iconTone: "light"
2588
+ },
2589
+ "circle-light": {
2590
+ iconStyle: "circle",
2591
+ iconTone: "light"
2592
+ },
2593
+ "rounded-color": { iconStyle: "rounded" },
2594
+ "rounded-colored": { iconStyle: "rounded" },
2595
+ "rounded-black": {
2596
+ iconStyle: "rounded",
2597
+ iconTone: "dark"
2598
+ },
2599
+ "rounded-dark": {
2600
+ iconStyle: "rounded",
2601
+ iconTone: "dark"
2602
+ },
2603
+ "rounded-dark-gray": {
2604
+ iconStyle: "rounded",
2605
+ iconTone: "dark"
2606
+ },
2607
+ "rounded-dark-grey": {
2608
+ iconStyle: "rounded",
2609
+ iconTone: "dark"
2610
+ },
2611
+ "rounded-white": {
2612
+ iconStyle: "rounded",
2613
+ iconTone: "light"
2614
+ },
2615
+ "rounded-light": {
2616
+ iconStyle: "rounded",
2617
+ iconTone: "light"
2618
+ },
2619
+ "square-color": { iconStyle: "square" },
2620
+ "square-colored": { iconStyle: "square" },
2621
+ "squared-color": { iconStyle: "square" },
2622
+ "squared-colored": { iconStyle: "square" },
2623
+ "square-black": {
2624
+ iconStyle: "square",
2625
+ iconTone: "dark"
2626
+ },
2627
+ "square-dark": {
2628
+ iconStyle: "square",
2629
+ iconTone: "dark"
2630
+ },
2631
+ "squared-black": {
2632
+ iconStyle: "square",
2633
+ iconTone: "dark"
2634
+ },
2635
+ "squared-dark": {
2636
+ iconStyle: "square",
2637
+ iconTone: "dark"
2638
+ },
2639
+ "square-dark-gray": {
2640
+ iconStyle: "square",
2641
+ iconTone: "dark"
2642
+ },
2643
+ "square-dark-grey": {
2644
+ iconStyle: "square",
2645
+ iconTone: "dark"
2646
+ },
2647
+ "squared-dark-gray": {
2648
+ iconStyle: "square",
2649
+ iconTone: "dark"
2650
+ },
2651
+ "squared-dark-grey": {
2652
+ iconStyle: "square",
2653
+ iconTone: "dark"
2654
+ },
2655
+ "square-white": {
2656
+ iconStyle: "square",
2657
+ iconTone: "light"
2658
+ },
2659
+ "square-light": {
2660
+ iconStyle: "square",
2661
+ iconTone: "light"
2662
+ },
2663
+ "squared-white": {
2664
+ iconStyle: "square",
2665
+ iconTone: "light"
2666
+ },
2667
+ "squared-light": {
2668
+ iconStyle: "square",
2669
+ iconTone: "light"
2670
+ }
2671
+ };
2672
+ function socialIconSet(descriptor) {
2673
+ const decodeSetName = (segment) => {
2674
+ try {
2675
+ return decodeURIComponent(segment);
2676
+ } catch {
2677
+ return segment;
2678
+ }
2679
+ };
2680
+ const icons = descriptor.iconsList?.icons;
2681
+ if (!Array.isArray(icons)) return void 0;
2682
+ for (const icon of icons) {
2683
+ if (!icon || typeof icon !== "object") continue;
2684
+ const src = icon.image?.src;
2685
+ if (typeof src !== "string") continue;
2686
+ const match = /social-networks-icon-sets\/([^/?#]+)\//i.exec(src);
2687
+ if (match) return decodeSetName(match[1]).toLowerCase();
2688
+ }
2689
+ }
2690
+ /**
2691
+ * The gap between icons. BeeFree's padding is the space on each side of an
2692
+ * icon, so two neighbours contribute both sides. Rounded once, after the sum.
2693
+ * `undefined` when the padding isn't a length, and the block keeps 10.
2694
+ */
2695
+ function socialSpacing(descriptor) {
2696
+ const padding = descriptor.computedStyle?.padding;
2697
+ if (typeof padding !== "string" || padding.trim() === "") return void 0;
2698
+ const parts = padding.trim().split(/\s+/);
2699
+ if (parts.length > 4) return void 0;
2700
+ const values = [];
2701
+ for (const part of parts) {
2702
+ const match = /^(\d+(?:\.\d+)?|\.\d+)(px)?$/i.exec(part);
2703
+ if (!match) return void 0;
2704
+ values.push(parseFloat(match[1]));
2705
+ }
2706
+ const right = values.length === 1 ? values[0] : values[1];
2707
+ const left = values.length === 4 ? values[3] : right;
2708
+ return Math.round(left + right);
2709
+ }
2710
+ /** Nearest of the block's 24 / 32 / 48px presets. */
2711
+ function socialIconSizeFromWidth(width) {
2712
+ if (width <= 0) return void 0;
2713
+ if (width < 28) return "small";
2714
+ if (width < 40) return "medium";
2715
+ return "large";
2716
+ }
2717
+ function socialAppearance(descriptor) {
2718
+ const spacing = socialSpacing(descriptor);
2719
+ const size = socialIconSizeFromWidth(computedPx(descriptor.computedStyle?.iconsDefaultWidth));
2720
+ const theme = SOCIAL_ICON_SETS[socialIconSet(descriptor) ?? ""];
2721
+ return {
2722
+ align: toAlign(descriptor.style?.["text-align"], "center"),
2723
+ ...spacing !== void 0 ? { spacing } : {},
2724
+ ...size ? { iconSize: size } : {},
2725
+ ...theme ? {
2726
+ iconStyle: theme.iconStyle,
2727
+ ...theme.iconTone ? { iconTone: theme.iconTone } : {}
2728
+ } : {}
2729
+ };
2730
+ }
879
2731
  function convertSocial(descriptor, warnings) {
880
2732
  const iconsList = descriptor.iconsList;
881
- if (!iconsList?.icons) return createSocialIconsBlock({ styles: makeStyles(descriptor) });
2733
+ const styles = makeStyles(descriptor);
2734
+ const appearance = socialAppearance(descriptor);
2735
+ if (!iconsList?.icons) return {
2736
+ block: createSocialIconsBlock({
2737
+ styles,
2738
+ ...appearance
2739
+ }),
2740
+ extras: [],
2741
+ notes: []
2742
+ };
882
2743
  const icons = [];
2744
+ const extras = [];
2745
+ const notes = [];
883
2746
  for (const beeIcon of iconsList.icons) {
884
- const id = (beeIcon.id ?? beeIcon.name ?? "").toLowerCase();
885
- const platform = SOCIAL_PLATFORM_MAP[id];
2747
+ const platform = socialPlatform(beeIcon);
886
2748
  if (!platform) {
887
- warnings.push(`Unrecognized social icon "${beeIcon.name || id}" was skipped.`);
2749
+ const kept = unknownSocialBlock(descriptor, beeIcon, warnings);
2750
+ if (!kept) continue;
2751
+ if (kept.block.type === "html" && kept.block.content === "") {
2752
+ notes.push(kept.note);
2753
+ continue;
2754
+ }
2755
+ extras.push(kept.block);
2756
+ notes.push(kept.note);
888
2757
  continue;
889
2758
  }
2759
+ const href = safeCopiedUrl(decodeEntitiesInTokens(beeIcon.image?.href ?? ""), "href", warnings);
890
2760
  icons.push({
891
2761
  id: generateId(),
892
2762
  platform,
893
- url: beeIcon.image?.href || "#"
2763
+ url: href || "#"
894
2764
  });
895
2765
  }
896
- return createSocialIconsBlock({
897
- icons,
898
- styles: makeStyles(descriptor)
899
- });
2766
+ if (icons.length === 0 && extras.length > 0) {
2767
+ const [block, ...rest] = extras;
2768
+ return {
2769
+ block,
2770
+ extras: rest,
2771
+ notes
2772
+ };
2773
+ }
2774
+ return {
2775
+ block: createSocialIconsBlock({
2776
+ icons,
2777
+ styles,
2778
+ ...appearance
2779
+ }),
2780
+ extras,
2781
+ notes
2782
+ };
900
2783
  }
901
2784
  /** Escape `& < > "` for text content and attribute values. */
902
2785
  function escapeHtml(value) {
@@ -932,7 +2815,7 @@ function iconSize(descriptor, item) {
932
2815
  * A lone icon without text, as an image block at the icon height. `undefined`
933
2816
  * when the module holds anything else, or the icon's file size is unknown.
934
2817
  */
935
- function convertSingleIcon(descriptor, columnWidth) {
2818
+ function convertSingleIcon(descriptor, columnWidth, paddingReturn) {
936
2819
  const items = iconItems(descriptor);
937
2820
  if (items.length !== 1) return void 0;
938
2821
  const [item] = items;
@@ -947,7 +2830,7 @@ function convertSingleIcon(descriptor, columnWidth) {
947
2830
  bottom: styles.padding.bottom + spacing.bottom,
948
2831
  left: styles.padding.left + spacing.left
949
2832
  };
950
- const room = columnWidth === void 0 ? void 0 : columnWidth - padding.left - padding.right;
2833
+ const room = measuredRoom(columnWidth, padding, paddingReturn);
951
2834
  return createImageBlock({
952
2835
  src,
953
2836
  alt: stringField(item.alt),
@@ -995,7 +2878,7 @@ function iconsHtml(descriptor) {
995
2878
  const cellAlign = stacked ? " align=\"center\"" : "";
996
2879
  const href = stringField(item.href);
997
2880
  const target = stringField(item.target);
998
- const link = (inner, linkStyle) => href ? `<a href="${escapeHtml(href)}"${target ? ` target="${escapeHtml(target)}"` : ""} style="${escapeHtml(linkStyle)}">${inner}</a>` : inner;
2881
+ const link = (inner, linkStyle) => href ? `<a href="${escapeHtml(href)}"${target ? ` target="${escapeHtml(target)}"` : ""} style=${quoteStyleValue(linkStyle)}>${inner}</a>` : inner;
999
2882
  const src = stringField(item.image);
1000
2883
  const { height, width } = iconSize(descriptor, item);
1001
2884
  const title = stringField(item.title);
@@ -1006,14 +2889,14 @@ function iconsHtml(descriptor) {
1006
2889
  `height: ${height}px;`,
1007
2890
  "border: 0;"
1008
2891
  ].filter(Boolean).join(" ");
1009
- const img = `<img src="${escapeHtml(src)}" alt="${escapeHtml(stringField(item.alt))}"${title ? ` title="${escapeHtml(title)}"` : ""}${width === void 0 ? "" : ` width="${width}"`} height="${height}" style="${imgStyle}">`;
1010
- const iconCell = src ? `<td${cellAlign} style="${iconPadding}vertical-align: middle;">${link(img, "text-decoration: none;")}</td>` : "";
2892
+ const img = `<img src="${escapeHtml(src)}" alt="${escapeHtml(stringField(item.alt))}"${title ? ` title="${escapeHtml(title)}"` : ""}${width === void 0 ? "" : ` width="${width}"`} height="${height}" style=${quoteStyleValue(imgStyle)}>`;
2893
+ const iconCell = src ? `<td${cellAlign} style=${quoteStyleValue(`${iconPadding}vertical-align: middle;`)}>${link(img, "text-decoration: none;")}</td>` : "";
1011
2894
  const text = stringField(item.text);
1012
- const textCell = text ? `<td${cellAlign} style="vertical-align: middle;${textStyle ? ` ${escapeHtml(textStyle)}` : ""}">${link(escapeHtml(text), `${color ? `color: ${color}; ` : ""}text-decoration: none;`)}</td>` : "";
2895
+ const textCell = text ? `<td${cellAlign} style=${quoteStyleValue(`vertical-align: middle;${textStyle ? ` ${textStyle}` : ""}`)}>${link(escapeHtml(text), `${color ? `color: ${color}; ` : ""}text-decoration: none;`)}</td>` : "";
1013
2896
  const body = (position === "top" ? [textCell, iconCell] : position === "bottom" ? [iconCell, textCell] : [position === "left" ? textCell + iconCell : iconCell + textCell]).filter(Boolean).map((row) => `<tr>${row}</tr>`).join("");
1014
- return `<td style="${index > 0 && gap ? `padding-left: ${gap}px; ` : ""}vertical-align: middle;"><table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: collapse;">${body}</table></td>`;
2897
+ return `<td style=${quoteStyleValue(`${index > 0 && gap ? `padding-left: ${gap}px; ` : ""}vertical-align: middle;`)}><table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: collapse;">${body}</table></td>`;
1015
2898
  });
1016
- return `<table role="presentation" border="0" cellpadding="0" cellspacing="0"${align === "left" ? "" : ` align="${align}"`} style="border-collapse: collapse;"><tr>${items.join("")}</tr></table>`;
2899
+ return decodeEntitiesInHtml(`<table role="presentation" border="0" cellpadding="0" cellspacing="0"${align === "left" ? "" : ` align="${align}"`} style="border-collapse: collapse;"><tr>${items.join("")}</tr></table>`);
1017
2900
  }
1018
2901
  /**
1019
2902
  * The Icons module's items without the link and image URLs the editor's HTML
@@ -1023,13 +2906,21 @@ function iconsHtml(descriptor) {
1023
2906
  */
1024
2907
  function safeIconItems(descriptor, warnings) {
1025
2908
  return iconItems(descriptor).map((item) => {
1026
- const safe = { ...item };
1027
- const image = stringField(item.image);
2909
+ const safe = {
2910
+ ...item,
2911
+ image: decodeEntitiesInTokens(stringField(item.image)),
2912
+ alt: decodeEntitiesInTokens(stringField(item.alt)),
2913
+ href: decodeEntitiesInTokens(stringField(item.href)),
2914
+ text: decodeEntitiesInTokens(stringField(item.text)),
2915
+ title: decodeEntitiesInTokens(stringField(item.title)),
2916
+ target: decodeEntitiesInTokens(stringField(item.target))
2917
+ };
2918
+ const image = stringField(safe.image);
1028
2919
  if (image && !isSafeHtmlUrl(image, "src")) {
1029
2920
  warnings.push(`Dropped the unsafe image "${image}" on an Icons item.`);
1030
2921
  safe.image = "";
1031
2922
  }
1032
- const href = stringField(item.href);
2923
+ const href = stringField(safe.href);
1033
2924
  if (href && !isSafeHtmlUrl(href, "href")) {
1034
2925
  warnings.push(`Dropped the unsafe link "${href}" on an Icons item.`);
1035
2926
  safe.href = "";
@@ -1037,12 +2928,12 @@ function safeIconItems(descriptor, warnings) {
1037
2928
  return safe;
1038
2929
  });
1039
2930
  }
1040
- function convertIcons(descriptor, columnWidth, warnings) {
2931
+ function convertIcons(descriptor, columnWidth, paddingReturn, warnings) {
1041
2932
  const safe = {
1042
2933
  ...descriptor,
1043
2934
  iconsList: { icons: safeIconItems(descriptor, warnings) }
1044
2935
  };
1045
- const single = convertSingleIcon(safe, columnWidth);
2936
+ const single = convertSingleIcon(safe, columnWidth, paddingReturn);
1046
2937
  if (single) return {
1047
2938
  block: single,
1048
2939
  rebuilt: false
@@ -1059,9 +2950,9 @@ function convertVideo(descriptor) {
1059
2950
  const video = descriptor.video;
1060
2951
  if (!video) return createVideoBlock({ styles: makeStyles(descriptor) });
1061
2952
  return createVideoBlock({
1062
- url: video.src || "",
1063
- thumbnailUrl: video.thumbnail || "",
1064
- alt: video.thumbAlt ?? video.alt ?? "",
2953
+ url: decodeEntitiesInTokens(video.src || ""),
2954
+ thumbnailUrl: decodeEntitiesInTokens(video.thumbnail || ""),
2955
+ alt: decodeEntitiesInTokens(video.thumbAlt ?? video.alt ?? ""),
1065
2956
  width: parsePxValue(video.style?.width) || 600,
1066
2957
  height: parsePxValue(video.style?.height) || void 0,
1067
2958
  align: toAlign(video.style?.["text-align"], "center"),
@@ -1074,15 +2965,15 @@ function convertMenu(descriptor) {
1074
2965
  const style = menu.style ?? {};
1075
2966
  const items = (menu.items ?? []).map((item) => ({
1076
2967
  id: generateId(),
1077
- text: item.text || "",
1078
- url: item.link || item.href || "#",
2968
+ text: decodeEntitiesInTokens(item.text || ""),
2969
+ url: decodeEntitiesInTokens(item.link || item.href || "#"),
1079
2970
  openInNewTab: item.target === "_blank",
1080
2971
  bold: false,
1081
2972
  underline: false
1082
2973
  }));
1083
2974
  return createMenuBlock({
1084
2975
  items,
1085
- separator: menu.separator || "|",
2976
+ separator: decodeEntitiesInTokens(menu.separator || "|"),
1086
2977
  separatorColor: parseColor(menu.separatorColor) || "#999999",
1087
2978
  fontSize: parsePxValue(style["font-size"]) || 14,
1088
2979
  color: parseColor(style.color) || void 0,
@@ -1103,7 +2994,7 @@ function convertTable(descriptor) {
1103
2994
  id: generateId(),
1104
2995
  cells: (row.cells ?? []).map((cell) => {
1105
2996
  const raw = cell.content ?? cell.html ?? "";
1106
- const content = stripTagsPlain(raw);
2997
+ const content = importedPlainText(raw);
1107
2998
  if (content !== raw) approximated = true;
1108
2999
  return {
1109
3000
  id: generateId(),
@@ -1135,7 +3026,7 @@ function convertHtmlFallback(module) {
1135
3026
  else if (descriptor.heading?.text) html = descriptor.heading.text;
1136
3027
  else html = `<!-- Unsupported BeeFree module: ${module.type} -->`;
1137
3028
  return createHtmlBlock({
1138
- content: html,
3029
+ content: importedHtml(html),
1139
3030
  styles: makeStyles(descriptor)
1140
3031
  });
1141
3032
  }
@@ -1157,6 +3048,7 @@ function convertModuleBase(module, warnings, context = {}) {
1157
3048
  }
1158
3049
  };
1159
3050
  let block;
3051
+ let extras = [];
1160
3052
  let isApproximation = false;
1161
3053
  let notes = [];
1162
3054
  switch (mappedType) {
@@ -1164,19 +3056,29 @@ function convertModuleBase(module, warnings, context = {}) {
1164
3056
  case "list":
1165
3057
  block = convertText(descriptor, textColor);
1166
3058
  break;
1167
- case "title":
1168
- block = convertHeading(descriptor, textColor);
3059
+ case "title": {
3060
+ const result = convertHeading(descriptor, textColor);
3061
+ block = result.block;
3062
+ if (result.note) {
3063
+ notes = [result.note];
3064
+ isApproximation = true;
3065
+ }
1169
3066
  break;
3067
+ }
1170
3068
  case "image": {
1171
- const result = convertImage(descriptor, context.columnWidth);
3069
+ const result = convertImage(descriptor, context.columnWidth, context.paddingReturn);
1172
3070
  block = result.block;
1173
3071
  notes = result.notes;
1174
3072
  isApproximation = notes.length > 0;
1175
3073
  break;
1176
3074
  }
1177
- case "button":
1178
- block = convertButton(descriptor);
3075
+ case "button": {
3076
+ const result = convertButton(descriptor);
3077
+ block = result.block;
3078
+ notes = result.notes;
3079
+ isApproximation = notes.length > 0;
1179
3080
  break;
3081
+ }
1180
3082
  case "divider": {
1181
3083
  const height = undrawnDividerHeight(descriptor);
1182
3084
  if (height !== void 0) {
@@ -1184,7 +3086,7 @@ function convertModuleBase(module, warnings, context = {}) {
1184
3086
  notes = [`The divider draws no line; imported as a ${height}px spacer.`];
1185
3087
  break;
1186
3088
  }
1187
- const result = convertDivider(descriptor, context.columnWidth);
3089
+ const result = convertDivider(descriptor, context.columnWidth, context.paddingReturn);
1188
3090
  block = result.block;
1189
3091
  notes = result.notes;
1190
3092
  isApproximation = notes.length > 0;
@@ -1194,7 +3096,7 @@ function convertModuleBase(module, warnings, context = {}) {
1194
3096
  block = convertSpacer(descriptor);
1195
3097
  break;
1196
3098
  case "icons": {
1197
- const result = convertIcons(descriptor, context.columnWidth, warnings);
3099
+ const result = convertIcons(descriptor, context.columnWidth, context.paddingReturn, warnings);
1198
3100
  if (result.rebuilt) return {
1199
3101
  block: result.block,
1200
3102
  entry: {
@@ -1210,9 +3112,14 @@ function convertModuleBase(module, warnings, context = {}) {
1210
3112
  case "html":
1211
3113
  block = convertHtml(descriptor);
1212
3114
  break;
1213
- case "social":
1214
- block = convertSocial(descriptor, warnings);
3115
+ case "social": {
3116
+ const result = convertSocial(descriptor, warnings);
3117
+ block = result.block;
3118
+ extras = result.extras;
3119
+ notes = result.notes;
3120
+ isApproximation = notes.length > 0;
1215
3121
  break;
3122
+ }
1216
3123
  case "video":
1217
3124
  block = convertVideo(descriptor);
1218
3125
  break;
@@ -1242,6 +3149,7 @@ function convertModuleBase(module, warnings, context = {}) {
1242
3149
  }
1243
3150
  return {
1244
3151
  block,
3152
+ ...extras.length > 0 ? { extras } : {},
1245
3153
  entry: {
1246
3154
  beeFreeModuleType: module.type,
1247
3155
  templaticalBlockType: block.type,
@@ -1303,7 +3211,7 @@ function convertModule(module, warnings, context = {}) {
1303
3211
  }
1304
3212
  values.items.forEach((item, index) => {
1305
3213
  for (const key of Object.keys(item ?? {}).filter((key) => key !== "text")) warn(warnings, `Dropped language "${locale}" key "${key}": no language mapping for this format.`);
1306
- if (typeof item?.text === "string") text(block, locale, `items.${block.items[index].id}.text`, block.items[index].text, item.text);
3214
+ if (typeof item?.text === "string") text(block, locale, `items.${block.items[index].id}.text`, block.items[index].text, decodeEntitiesInTokens(item.text));
1307
3215
  });
1308
3216
  } else if (key && typeof values[field] === "string") {
1309
3217
  const translated = convertModuleBase({
@@ -1325,20 +3233,85 @@ function convertModule(module, warnings, context = {}) {
1325
3233
  }
1326
3234
  // src/converter.ts
1327
3235
  /**
3236
+ * A column's share of BeeFree's 12-column grid. An omitted value counts as 6,
3237
+ * half the grid, which is what a 2-column row uses when neither side states one.
3238
+ */
3239
+ function columnUnits(column) {
3240
+ const value = column["grid-columns"];
3241
+ return typeof value === "number" && Number.isFinite(value) ? value : 6;
3242
+ }
3243
+ function gridLabel(columns) {
3244
+ return columns.map(columnUnits).join("-");
3245
+ }
3246
+ function columnsEqual(columns) {
3247
+ const first = columnUnits(columns[0]);
3248
+ return columns.every((column) => columnUnits(column) === first);
3249
+ }
3250
+ /**
3251
+ * Thousandths of the left column's share. 8/12 and 2/3 both land on 667 and
3252
+ * 7/12 lands on 583, so an exact layout and a near one stay apart.
3253
+ */
3254
+ function ratioMilli(left, total) {
3255
+ return Math.round(left / total * 1e3);
3256
+ }
3257
+ function isExactLayout(layout, left, total) {
3258
+ const milli = ratioMilli(left, total);
3259
+ if (layout === "2") return milli === 500;
3260
+ if (layout === "2-1") return milli === 667;
3261
+ return milli === 333;
3262
+ }
3263
+ function noteSnappedRatio(columns, layout, warnings) {
3264
+ warnings.push(`Column ratio ${gridLabel(columns)} was mapped to the nearest layout "${layout}".`);
3265
+ }
3266
+ /**
3267
+ * Nearest two-column layout, in the same thousandths as `isExactLayout`.
3268
+ * `2` is listed first, so an equal distance stays on the even split. 7/12
3269
+ * rounds to 583: 83 from even, 84 from 2/3, so the narrower share is not
3270
+ * squeezed to a third.
3271
+ */
3272
+ function nearestTwoColumn(left, total) {
3273
+ const milli = ratioMilli(left, total);
3274
+ const choices = [
3275
+ ["2", 500],
3276
+ ["2-1", 667],
3277
+ ["1-2", 333]
3278
+ ];
3279
+ let best = "2";
3280
+ let bestDistance = Math.abs(milli - 500);
3281
+ for (const [layout, target] of choices) {
3282
+ const distance = Math.abs(milli - target);
3283
+ if (distance < bestDistance) {
3284
+ best = layout;
3285
+ bestDistance = distance;
3286
+ }
3287
+ }
3288
+ return best;
3289
+ }
3290
+ /**
1328
3291
  * Determines the Templatical ColumnLayout from BeeFree column grid values.
3292
+ * A split that is not exactly `2`, `1-2`, `2-1` or an even `3` keeps the
3293
+ * nearest of those and records one warning naming the source ratio. Four or
3294
+ * more columns flatten to one column, with that ratio in the same warning.
1329
3295
  */
1330
3296
  function resolveColumnLayout(columns, warnings) {
1331
3297
  if (columns.length === 1) return null;
1332
- if (columns.length === 3) return "3";
3298
+ if (columns.length === 3) {
3299
+ if (!columnsEqual(columns)) noteSnappedRatio(columns, "3", warnings);
3300
+ return "3";
3301
+ }
1333
3302
  if (columns.length === 2) {
1334
- const left = columns[0]["grid-columns"] ?? 6;
1335
- const ratio = left / (left + (columns[1]["grid-columns"] ?? 6));
1336
- if (ratio > .58) return "2-1";
1337
- if (ratio < .42) return "1-2";
1338
- return "2";
3303
+ const left = columnUnits(columns[0]);
3304
+ const total = left + columnUnits(columns[1]);
3305
+ if (total <= 0) {
3306
+ noteSnappedRatio(columns, "2", warnings);
3307
+ return "2";
3308
+ }
3309
+ const layout = nearestTwoColumn(left, total);
3310
+ if (!isExactLayout(layout, left, total)) noteSnappedRatio(columns, layout, warnings);
3311
+ return layout;
1339
3312
  }
1340
3313
  if (columns.length >= 4) {
1341
- warnings.push(`Row with ${columns.length} columns was flattened to a single column. BeeFree supports arbitrary columns, but Templatical supports up to 3.`);
3314
+ warnings.push(`Row with ${columns.length} columns (${gridLabel(columns)}) was flattened to a single column. BeeFree supports arbitrary columns, but Templatical supports up to 3.`);
1342
3315
  return null;
1343
3316
  }
1344
3317
  return "2";
@@ -1371,8 +3344,9 @@ function convertColumnModules(column, context, entries, warnings) {
1371
3344
  entries.push(skipped);
1372
3345
  continue;
1373
3346
  }
1374
- const { block, entry } = convertModule(module, warnings, context);
3347
+ const { block, entry, extras } = convertModule(module, warnings, context);
1375
3348
  blocks.push(block);
3349
+ if (extras?.length) blocks.push(...extras);
1376
3350
  entries.push(entry);
1377
3351
  }
1378
3352
  return blocks;
@@ -1388,6 +3362,8 @@ function convertColumnModules(column, context, entries, warnings) {
1388
3362
  function resolveRowBackgroundImage(row, rowBg, width, warnings) {
1389
3363
  const content = readBeefreeBackgroundImage(row.content?.style, "Row", width);
1390
3364
  const band = readBeefreeBackgroundImage(row.container?.style, "Row container", width);
3365
+ if (content) content.image.url = decodeEntitiesInTokens(content.image.url);
3366
+ if (band) band.image.url = decodeEntitiesInTokens(band.image.url);
1391
3367
  if (content) {
1392
3368
  warnings.push(...content.notes);
1393
3369
  if (band) warnings.push(`Row container background image "${band.image.url}" outside the content width was dropped; the section keeps the content background image.`);
@@ -1401,6 +3377,242 @@ function resolveRowBackgroundImage(row, rowBg, width, warnings) {
1401
3377
  warnings.push(...band.notes, `Row container background image "${band.image.url}" spans the full width in BeeFree; the section paints it at the content width.`);
1402
3378
  return band.image;
1403
3379
  }
3380
+ function zeroPadding() {
3381
+ return {
3382
+ top: 0,
3383
+ right: 0,
3384
+ bottom: 0,
3385
+ left: 0
3386
+ };
3387
+ }
3388
+ function addPadding(base, extra) {
3389
+ return {
3390
+ top: base.top + extra.top,
3391
+ right: base.right + extra.right,
3392
+ bottom: base.bottom + extra.bottom,
3393
+ left: base.left + extra.left
3394
+ };
3395
+ }
3396
+ function hasPadding(padding) {
3397
+ return padding.top !== 0 || padding.right !== 0 || padding.bottom !== 0 || padding.left !== 0;
3398
+ }
3399
+ const BORDER_SIDES$1 = [
3400
+ "top",
3401
+ "right",
3402
+ "bottom",
3403
+ "left"
3404
+ ];
3405
+ function toBorderStyle(value) {
3406
+ if (value === "dashed" || value === "dotted" || value === "solid") return value;
3407
+ return "solid";
3408
+ }
3409
+ /**
3410
+ * A column background that paints. Fully transparent is unset, the same way
3411
+ * a border that paints nothing is.
3412
+ */
3413
+ function columnBackgroundColor(style) {
3414
+ const raw = style?.["background-color"];
3415
+ if (isTransparentColor(raw)) return "";
3416
+ return parseColor(raw);
3417
+ }
3418
+ /**
3419
+ * A column or row style's border as a section border. A side that paints
3420
+ * nothing has width 0. `undefined` when no side is drawn.
3421
+ */
3422
+ function columnBorder(style) {
3423
+ if (!style) return void 0;
3424
+ let drawn = false;
3425
+ const border = {};
3426
+ for (const side of BORDER_SIDES$1) {
3427
+ const raw = style[`border-${side}`];
3428
+ const parsed = raw ? parseBorderTop(raw) : void 0;
3429
+ const paints = parsed !== void 0 && parsed.drawn && parsed.width > 0;
3430
+ if (paints) drawn = true;
3431
+ border[side] = {
3432
+ width: paints ? parsed.width : 0,
3433
+ style: toBorderStyle(parsed?.style),
3434
+ color: parsed?.color || "#000000"
3435
+ };
3436
+ }
3437
+ return drawn ? border : void 0;
3438
+ }
3439
+ function isFlag(value) {
3440
+ return value === true || value === "true";
3441
+ }
3442
+ function rowVisibility(row) {
3443
+ const computed = row.content?.computedStyle;
3444
+ const hideMobile = isFlag(computed?.hideContentOnMobile);
3445
+ const hideDesktop = isFlag(computed?.hideContentOnDesktop);
3446
+ if (!hideMobile && !hideDesktop) return void 0;
3447
+ return {
3448
+ desktop: !hideDesktop,
3449
+ mobile: !hideMobile
3450
+ };
3451
+ }
3452
+ function rowOptsOutOfStack(row) {
3453
+ const value = row.content?.computedStyle?.rowColStackOnMobile;
3454
+ return value === false || value === "false";
3455
+ }
3456
+ function sameBorder(a, b) {
3457
+ return BORDER_SIDES$1.every((side) => a[side].width === b[side].width && a[side].style === b[side].style && a[side].color === b[side].color);
3458
+ }
3459
+ const ROW_BORDER_DROPPED = "Row border was dropped; the column border is kept on the section.";
3460
+ const ROW_STACK_DROPPED = "rowColStackOnMobile false was dropped; Templatical keeps columns side by side only on a 2- or 3-column section.";
3461
+ /**
3462
+ * Row chrome the section can hold. The container colour is the band outside
3463
+ * the content box (`wrapper`); the section fill stays the content colour the
3464
+ * caller already chose. A band the same colour as that fill adds nothing. A
3465
+ * column border already chosen for the section wins over the row border.
3466
+ */
3467
+ function rowFrame(row, sectionBackground, columnBorderValue, applyStack, warnings) {
3468
+ const visibility = rowVisibility(row);
3469
+ const band = parseColor(row.container?.style?.["background-color"]);
3470
+ const wrapper = band && band !== sectionBackground ? { backgroundColor: band } : void 0;
3471
+ let border = columnBorderValue;
3472
+ const rowBorder = columnBorder(row.content?.style);
3473
+ if (rowBorder) {
3474
+ if (!border) border = rowBorder;
3475
+ else if (!sameBorder(border, rowBorder)) warnings.push(ROW_BORDER_DROPPED);
3476
+ }
3477
+ let stackOnMobile;
3478
+ if (rowOptsOutOfStack(row)) {
3479
+ if (applyStack) stackOnMobile = false;
3480
+ else warnings.push(ROW_STACK_DROPPED);
3481
+ }
3482
+ return {
3483
+ ...visibility ? { visibility } : {},
3484
+ ...stackOnMobile === false ? { stackOnMobile } : {},
3485
+ ...wrapper ? { wrapper } : {},
3486
+ ...border ? { border } : {}
3487
+ };
3488
+ }
3489
+ function frameNeedsSection(frame) {
3490
+ return frame.visibility !== void 0 || frame.wrapper !== void 0 || frame.border !== void 0;
3491
+ }
3492
+ /**
3493
+ * `content.style.width` is the content box. A section has no width of its
3494
+ * own, so a value other than the email width is reported and left.
3495
+ */
3496
+ function warnIfContentWidthDiffers(row, emailWidth, warnings) {
3497
+ const raw = row.content?.style?.width;
3498
+ if (typeof raw !== "string" || raw.trim() === "") return;
3499
+ const px = parsePxValue(raw);
3500
+ if (px > 0 && px === emailWidth) return;
3501
+ const shown = px > 0 ? `${px}px` : `"${raw.trim()}"`;
3502
+ warnings.push(`Row content width ${shown} differs from the email width ${emailWidth}px. A section has no width of its own, so the row keeps the email width.`);
3503
+ }
3504
+ const COLUMN_BOX_DROPPED = "Column background/border was dropped; a multi-column row has no per-column box.";
3505
+ /**
3506
+ * The percentages the renderer emits for a multi-column section
3507
+ * (`getWidthPercentages`). 33.33% of 600 is 199; the last column is 33.34%.
3508
+ */
3509
+ const COLUMN_CONTENT_PERCENTS = {
3510
+ "2": ["50%", "50%"],
3511
+ "3": [
3512
+ "33.33%",
3513
+ "33.33%",
3514
+ "33.34%"
3515
+ ],
3516
+ "1-2": ["33.33%", "66.67%"],
3517
+ "2-1": ["66.67%", "33.33%"]
3518
+ };
3519
+ function drawnSide(side) {
3520
+ return toBorderCss(side) === null ? 0 : Math.trunc(side.width);
3521
+ }
3522
+ /**
3523
+ * BeeFree's grid share of the section box, in whole pixels. The same floor
3524
+ * the renderer uses, so an exact layout and this share differ only where
3525
+ * the percentage rounding does.
3526
+ */
3527
+ function columnShareWidths(columns, box) {
3528
+ const units = columns.map(columnUnits);
3529
+ const total = units.reduce((sum, share) => sum + share, 0);
3530
+ if (total <= 0) {
3531
+ const each = Math.floor(box / Math.max(columns.length, 1));
3532
+ return columns.map(() => each);
3533
+ }
3534
+ return units.map((share) => Math.floor(box * share / total));
3535
+ }
3536
+ /** True when `layout` is not the source ratio, so a column may be narrower. */
3537
+ function layoutSnaps(columns, layout) {
3538
+ if (layout === "3") return !columnsEqual(columns);
3539
+ const total = columns.reduce((sum, column) => sum + columnUnits(column), 0);
3540
+ return !isExactLayout(layout, columnUnits(columns[0]), total);
3541
+ }
3542
+ /**
3543
+ * A snapped column is narrower than its grid share. The missing pixels come
3544
+ * back from the padding that faces the next column, and the outer edge stays.
3545
+ * Nothing is taken past 0, and a wider column is left alone. Image, lone-icon
3546
+ * and divider widths are measured with this padding already returned.
3547
+ */
3548
+ function giveBackInnerPadding(blocks, deficit, sides) {
3549
+ if (deficit <= 0) return;
3550
+ for (const block of blocks) {
3551
+ const padding = block.styles.padding;
3552
+ let remaining = deficit;
3553
+ for (const side of sides) {
3554
+ if (remaining <= 0) break;
3555
+ const have = padding[side];
3556
+ if (have <= 0) continue;
3557
+ const give = Math.min(remaining, have);
3558
+ padding[side] = have - give;
3559
+ remaining -= give;
3560
+ }
3561
+ }
3562
+ }
3563
+ /**
3564
+ * Whole pixels MJML gives each column's content. A multi-column section
3565
+ * imported here has no padding of its own; a row border still shrinks the box.
3566
+ */
3567
+ function renderedColumnContentWidths(layout, bodyWidth, border) {
3568
+ const sectionBox = Math.floor(bodyWidth) - drawnSide(border?.left) - drawnSide(border?.right);
3569
+ return COLUMN_CONTENT_PERCENTS[layout].map((width) => Math.floor(sectionBox * parseFloat(width) / 100));
3570
+ }
3571
+ /**
3572
+ * True when the renderer would emit `fluid-on-mobile` on its own: `"full"`,
3573
+ * or a pixel width at or above the room inside the image's side padding and
3574
+ * border.
3575
+ */
3576
+ function imageFillsColumn(block, columnContentWidth) {
3577
+ const padding = block.styles.padding;
3578
+ const room = Math.floor(columnContentWidth) - Math.trunc(padding.left) - Math.trunc(padding.right) - drawnSide(block.border?.left) - drawnSide(block.border?.right);
3579
+ return block.width === "full" || block.width >= room;
3580
+ }
3581
+ /**
3582
+ * BeeFree does not widen an image on a phone when the image sits in a column
3583
+ * narrower than the email. Pin those that fill their column, which is when
3584
+ * the renderer would stretch them. A full-width row stays on the automatic
3585
+ * rule.
3586
+ */
3587
+ function keepColumnImagesAtDesktopWidth(blocks, columnContentWidth) {
3588
+ for (const block of blocks) {
3589
+ if (!isImage(block) || block.fluidOnMobile !== void 0) continue;
3590
+ if (imageFillsColumn(block, columnContentWidth)) block.fluidOnMobile = false;
3591
+ }
3592
+ }
3593
+ /** One warning per row when a column's background or border has nowhere to go. */
3594
+ function warnIfColumnBoxDropped(columns, warnings) {
3595
+ if (columns.some((column) => {
3596
+ const style = column.style;
3597
+ return columnBackgroundColor(style) !== "" || columnBorder(style) !== void 0;
3598
+ })) warnings.push(COLUMN_BOX_DROPPED);
3599
+ }
3600
+ /**
3601
+ * Column padding has no per-column box. Left and right sit on every module,
3602
+ * top on the first and bottom on the last, so the stack keeps the column's
3603
+ * inset.
3604
+ */
3605
+ function foldColumnPadding(blocks, padding) {
3606
+ if (blocks.length === 0 || !hasPadding(padding)) return;
3607
+ const last = blocks.length - 1;
3608
+ blocks.forEach((block, index) => {
3609
+ const own = block.styles.padding;
3610
+ own.left += padding.left;
3611
+ own.right += padding.right;
3612
+ if (index === 0) own.top += padding.top;
3613
+ if (index === last) own.bottom += padding.bottom;
3614
+ });
3615
+ }
1404
3616
  /**
1405
3617
  * Processes a single BeeFree row into one or more Templatical blocks.
1406
3618
  */
@@ -1409,50 +3621,93 @@ function processRow(row, settings, entries, warnings) {
1409
3621
  if (!columns || columns.length === 0) return [];
1410
3622
  if (row.locked) warnings.push("A locked row was imported. Lock metadata was dropped; content is preserved.");
1411
3623
  if (row.synced) warnings.push("A synced row was imported. Sync metadata was dropped; content is preserved.");
3624
+ warnIfContentWidthDiffers(row, settings.width, warnings);
1412
3625
  const layout = resolveColumnLayout(columns, warnings);
1413
3626
  const rowBg = parseColor(row.content?.style?.["background-color"]);
1414
3627
  const backgroundImage = resolveRowBackgroundImage(row, rowBg, settings.width, warnings);
1415
- if (!layout) {
1416
- const blocks = [];
3628
+ if (layout === null || layout === "1") {
1417
3629
  const context = {
1418
3630
  columnWidth: settings.width,
1419
3631
  textColor: settings.textColor
1420
3632
  };
1421
- for (const column of columns) blocks.push(...convertColumnModules(column, context, entries, warnings));
1422
- if (rowBg || backgroundImage) return [createSectionBlock({
3633
+ if (columns.length === 1) {
3634
+ const column = columns[0];
3635
+ const blocks = convertColumnModules(column, context, entries, warnings);
3636
+ const padding = addPadding(zeroPadding(), extractPadding(column.style));
3637
+ const backgroundColor = columnBackgroundColor(column.style) || rowBg;
3638
+ const frame = rowFrame(row, backgroundColor, columnBorder(column.style), false, warnings);
3639
+ if (backgroundColor || backgroundImage || hasPadding(padding) || frameNeedsSection(frame)) return [createSectionBlock({
3640
+ columns: "1",
3641
+ children: [blocks],
3642
+ styles: {
3643
+ padding,
3644
+ ...backgroundColor ? { backgroundColor } : {}
3645
+ },
3646
+ ...backgroundImage ? { backgroundImage } : {},
3647
+ ...frame
3648
+ })];
3649
+ return blocks;
3650
+ }
3651
+ const blocks = [];
3652
+ for (const column of columns) {
3653
+ const converted = convertColumnModules(column, context, entries, warnings);
3654
+ foldColumnPadding(converted, extractPadding(column.style));
3655
+ blocks.push(...converted);
3656
+ }
3657
+ warnIfColumnBoxDropped(columns, warnings);
3658
+ const frame = rowFrame(row, rowBg, void 0, false, warnings);
3659
+ if (rowBg || backgroundImage || frameNeedsSection(frame)) return [createSectionBlock({
1423
3660
  columns: "1",
1424
3661
  children: [blocks],
1425
3662
  styles: {
1426
- padding: {
1427
- top: 0,
1428
- right: 0,
1429
- bottom: 0,
1430
- left: 0
1431
- },
3663
+ padding: zeroPadding(),
1432
3664
  ...rowBg ? { backgroundColor: rowBg } : {}
1433
3665
  },
1434
- ...backgroundImage ? { backgroundImage } : {}
3666
+ ...backgroundImage ? { backgroundImage } : {},
3667
+ ...frame
1435
3668
  })];
1436
3669
  return blocks;
1437
3670
  }
1438
3671
  const widths = columnPixels(layout, settings.width);
1439
- const children = columns.map((col, index) => convertColumnModules(col, {
1440
- columnWidth: widths[index],
1441
- textColor: settings.textColor
1442
- }, entries, warnings));
3672
+ const frame = rowFrame(row, rowBg, void 0, true, warnings);
3673
+ const contentWidths = renderedColumnContentWidths(layout, settings.width, frame.border);
3674
+ const sectionBox = Math.floor(settings.width) - drawnSide(frame.border?.left) - drawnSide(frame.border?.right);
3675
+ const sourceWidths = layoutSnaps(columns, layout) ? columnShareWidths(columns, sectionBox) : null;
3676
+ const facing = (index) => {
3677
+ const last = columns.length - 1;
3678
+ if (index === 0) return ["right"];
3679
+ if (index === last) return ["left"];
3680
+ return ["right", "left"];
3681
+ };
3682
+ const children = columns.map((col, index) => {
3683
+ const deficit = sourceWidths ? sourceWidths[index] - (contentWidths[index] ?? 0) : 0;
3684
+ const columnPadding = extractPadding(col.style);
3685
+ const blocks = convertColumnModules(col, {
3686
+ columnWidth: widths[index],
3687
+ textColor: settings.textColor,
3688
+ ...sourceWidths ? { paddingReturn: {
3689
+ deficit: Math.max(0, deficit),
3690
+ sides: facing(index),
3691
+ columnPadding
3692
+ } } : {}
3693
+ }, entries, warnings);
3694
+ foldColumnPadding(blocks, columnPadding);
3695
+ return blocks;
3696
+ });
3697
+ warnIfColumnBoxDropped(columns, warnings);
3698
+ children.forEach((column, index) => {
3699
+ if (sourceWidths) giveBackInnerPadding(column, sourceWidths[index] - (contentWidths[index] ?? 0), facing(index));
3700
+ keepColumnImagesAtDesktopWidth(column, contentWidths[index] ?? 0);
3701
+ });
1443
3702
  return [createSectionBlock({
1444
3703
  columns: layout,
1445
3704
  children,
1446
3705
  styles: {
1447
- padding: {
1448
- top: 0,
1449
- right: 0,
1450
- bottom: 0,
1451
- left: 0
1452
- },
3706
+ padding: zeroPadding(),
1453
3707
  ...rowBg ? { backgroundColor: rowBg } : {}
1454
3708
  },
1455
- ...backgroundImage ? { backgroundImage } : {}
3709
+ ...backgroundImage ? { backgroundImage } : {},
3710
+ ...frame
1456
3711
  })];
1457
3712
  }
1458
3713
  /**
@@ -1503,17 +3758,17 @@ function convertBeeFreeTemplate(template, options = {}) {
1503
3758
  const entries = [];
1504
3759
  const warnings = [];
1505
3760
  const blocks = [];
1506
- const webFonts = template.page.body?.webFonts;
1507
- if (webFonts && webFonts.length > 1) warnings.push(`Template uses ${webFonts.length} web fonts. Only the primary font is preserved; additional fonts may need to be configured via the fonts option.`);
3761
+ const customFonts = collectCustomFonts(template.page.body?.webFonts);
3762
+ warnForMissingFontUrls(template, customFonts, warnings);
1508
3763
  const settings = extractSettings(template);
1509
3764
  const declared = template.page.body?.language?.value ?? template.page.language?.value ?? template.language?.value;
1510
3765
  settings.locale = (declared ? localeTag(declared, warnings) : null) ?? defaultLocaleOption(options.defaultLocale, warnings) ?? "en";
1511
3766
  const meta = template.head?.meta;
1512
- if (typeof meta?.preheader === "string") settings.preheaderText = meta.preheader;
3767
+ if (typeof meta?.preheader === "string") settings.preheaderText = decodeEntitiesInTokens(meta.preheader);
1513
3768
  for (const [code, values] of Object.entries(meta?.translations ?? {})) {
1514
3769
  const locale = localeTag(code, warnings);
1515
3770
  if (!locale) continue;
1516
- for (const key of Object.keys(values)) if (key === "preheader" && typeof values.preheader === "string") text(settings, locale, "preheaderText", settings.preheaderText, values.preheader);
3771
+ for (const key of Object.keys(values)) if (key === "preheader" && typeof values.preheader === "string") text(settings, locale, "preheaderText", settings.preheaderText, decodeEntitiesInTokens(values.preheader));
1517
3772
  else warn(warnings, `Dropped language "${locale}" metadata "${key}": ${[
1518
3773
  "subject",
1519
3774
  "title",
@@ -1542,8 +3797,93 @@ function convertBeeFreeTemplate(template, options = {}) {
1542
3797
  htmlFallback: entries.filter((e) => e.status === "html-fallback").length,
1543
3798
  skipped: entries.filter((e) => e.status === "skipped").length
1544
3799
  }
3800
+ },
3801
+ customFonts
3802
+ };
3803
+ }
3804
+ /**
3805
+ * Families BeeFree ships as system stacks, beyond the built-in web-safe set.
3806
+ * They show up as the face itself or as a fallback (`Helvetica Neue`,
3807
+ * `Lucida Grande`) and have nothing to download.
3808
+ */
3809
+ const WEB_SAFE_FONTS = /* @__PURE__ */ new Set([
3810
+ ...Object.keys(BUILT_IN_FONT_STACKS),
3811
+ "helvetica neue",
3812
+ "lucida grande",
3813
+ "lucida sans unicode",
3814
+ "lucida sans",
3815
+ "geneva",
3816
+ "times",
3817
+ "courier",
3818
+ "segoe",
3819
+ "segoe ui",
3820
+ "baskerville"
3821
+ ]);
3822
+ /** The first real family in a `font-family` value. `""` for inherit or a generic. */
3823
+ function declaredFontName(value) {
3824
+ if (!value) return "";
3825
+ return parseFontFamilies(value).find((family) => !isGenericFontFamily(family))?.name ?? "";
3826
+ }
3827
+ function isWebSafeFont(name) {
3828
+ return WEB_SAFE_FONTS.has(name.toLowerCase());
3829
+ }
3830
+ /**
3831
+ * `webFonts` entries a host can load. The name is the first family of
3832
+ * `fontFamily` (what the template's CSS names), then `name`. A duplicate
3833
+ * name keeps the first URL. A system face, `inherit`, or a blank URL is
3834
+ * skipped.
3835
+ */
3836
+ function collectCustomFonts(webFonts) {
3837
+ const fonts = [];
3838
+ const seen = /* @__PURE__ */ new Set();
3839
+ for (const font of webFonts ?? []) {
3840
+ const url = typeof font.url === "string" ? font.url.trim() : "";
3841
+ if (!url) continue;
3842
+ const name = declaredFontName(font.fontFamily) || declaredFontName(font.name);
3843
+ if (!name || isWebSafeFont(name)) continue;
3844
+ const key = name.toLowerCase();
3845
+ if (seen.has(key)) continue;
3846
+ seen.add(key);
3847
+ fonts.push({
3848
+ name,
3849
+ url
3850
+ });
3851
+ }
3852
+ return fonts;
3853
+ }
3854
+ /** `font-family` values on the body and in modules, not the `webFonts` list. */
3855
+ function collectUsedFontValues(template) {
3856
+ const found = [];
3857
+ const take = (value) => {
3858
+ if (typeof value === "string") {
3859
+ for (const match of value.matchAll(/font-family\s*:\s*((?:[^;}"]|"[^"]*")+)/gi)) found.push(match[1].trim());
3860
+ return;
3861
+ }
3862
+ if (!value || typeof value !== "object") return;
3863
+ if (Array.isArray(value)) {
3864
+ for (const item of value) take(item);
3865
+ return;
3866
+ }
3867
+ for (const [key, child] of Object.entries(value)) {
3868
+ if (key === "webFonts") continue;
3869
+ if (key === "font-family" && typeof child === "string") found.push(child);
3870
+ else take(child);
1545
3871
  }
1546
3872
  };
3873
+ take(template.page.body?.content);
3874
+ take(template.page.rows);
3875
+ return found;
3876
+ }
3877
+ function warnForMissingFontUrls(template, customFonts, warnings) {
3878
+ const loaded = new Set(customFonts.map((font) => font.name.toLowerCase()));
3879
+ for (const value of collectUsedFontValues(template)) {
3880
+ const name = declaredFontName(value);
3881
+ if (!name || isWebSafeFont(name)) continue;
3882
+ const key = name.toLowerCase();
3883
+ if (loaded.has(key)) continue;
3884
+ loaded.add(key);
3885
+ warn(warnings, `Font "${name}" is used but BeeFree provides no URL for it.`);
3886
+ }
1547
3887
  }
1548
3888
  export { convertBeeFreeTemplate };
1549
3889