@seliseblocks/mailcraft 0.2.9 → 0.2.11

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.
@@ -136,9 +136,38 @@ export function decorateLogicTags(html) {
136
136
  return s;
137
137
  }
138
138
 
139
- export function buildHtml(state, root, boxCss) {
139
+ export function buildHtml(state, root, boxCss, opts) {
140
140
  const d = state.doc; const t = d.theme;
141
141
  const logic = logicPlan(d);
142
+ /*
143
+ * Fidelity markers. A handful of blocks render into markup that cannot be
144
+ * read back into the block it came from: a countdown bakes its digits, a
145
+ * video is just a linked image, a section box and a code sample are styled
146
+ * divs like any other, a raw-CSS block is a bare <style>, and a flex/grid
147
+ * row is a div no table walker can re-shape. For exactly those, the export
148
+ * stamps a compact attribute layer -- `data-mc` (the type), `data-mcp`
149
+ * (the props the DOM itself does not carry), `data-mcr` (a CSS-layout
150
+ * row's settings) and the inert `mc-keep` class -- which the importer
151
+ * trusts when present and ignores otherwise. Mail clients ignore unknown
152
+ * attributes wholesale, and everything else round-trips from its rendered
153
+ * shape alone, so the layer stays this small. Attribute names deliberately
154
+ * sit outside `grab()`'s `data-mc-[a-z-]+` strip (they live on exporter
155
+ * scaffolding, but belt and braces). A host that wants pristine HTML can
156
+ * pass `exportHtml({ markers: false })` and accept the lossy reload.
157
+ */
158
+ const markers = !(opts && opts.markers === false);
159
+ const attrEsc = (v) => String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
160
+ const MARKED = { countdown: 1, video: 1, box: 1, codeblock: 1 };
161
+ const marker = (b) => {
162
+ if (!markers || !MARKED[b.type]) return '';
163
+ const payload = { ...b.props };
164
+ // Content the rendered DOM already carries is not duplicated into the
165
+ // payload: a box's html IS the grabbed markup, a code sample's text IS
166
+ // the <pre>'s own.
167
+ if (b.type === 'box') delete payload.html;
168
+ if (b.type === 'codeblock') delete payload.code;
169
+ return ' data-mc="' + b.type + '" data-mcp="' + attrEsc(JSON.stringify(payload)) + '"';
170
+ };
142
171
  const grab = (id) => {
143
172
  const el = root.querySelector('[data-mc-content="' + id + '"]');
144
173
  if (!el) return '';
@@ -174,7 +203,11 @@ export function buildHtml(state, root, boxCss) {
174
203
  */
175
204
  const mobilePlan = (rp, cols) => {
176
205
  const mode = rp.mobileCols === undefined ? 1 : rp.mobileCols;
177
- if (cols < 2 || mode === 'keep') return { row: '', cell: '' };
206
+ if (cols < 2) return { row: '', cell: '' };
207
+ // `keep` exports NO functional class, which made it indistinguishable
208
+ // from foreign HTML (where stacking is the safer default) -- so it ships
209
+ // as the inert `mc-keep`, a marker with no stylesheet rule behind it.
210
+ if (mode === 'keep') return { row: markers ? 'mc-keep' : '', cell: '' };
178
211
  const rowCls = [];
179
212
  // Two-up and reverse both need the row to become a flex container, which
180
213
  // is safe precisely because it only ever runs inside the media query: a
@@ -208,11 +241,15 @@ export function buildHtml(state, root, boxCss) {
208
241
  return blocksOf.map((b) => logic.emit.get(b.id) || '').filter(Boolean).map((s) => ' ' + s).join('\n');
209
242
  }
210
243
  const colInner = (c) => c.blocks.map((b) => {
211
- if (b.type === 'css') return '<style>' + (b.props.code || '') + '</style>';
244
+ if (b.type === 'css') return '<style' + (markers ? ' data-mc="css"' + (b.props.note ? ' data-mcn="' + attrEsc(b.props.note) + '"' : '') : '') + '>' + (b.props.code || '') + '</style>';
212
245
  if (b.type === 'html') return b.props.code || '';
213
- if (b.type === 'svg') return '<div style="text-align:' + b.props.align + ';padding:' + b.props.py + 'px 0">' + (b.props.code || '') + '</div>';
246
+ // The same width-carrying span the canvas draws (block-body.js), so the
247
+ // Width slider reaches the sent mail and the importer can read it back
248
+ // -- exported bare, the drawing shipped at its intrinsic size and the
249
+ // prop reset to 100 on every reload.
250
+ if (b.type === 'svg') return '<div style="text-align:' + b.props.align + ';padding:' + b.props.py + 'px 0"><span style="display:inline-block;width:' + (b.props.width || 100) + '%">' + (b.props.code || '') + '</span></div>';
214
251
  if (b.type === 'condition' || b.type === 'loop') return logic.emit.get(b.id) || '';
215
- return '<div' + visClass(b.props) + ' style="' + boxCss(b.props) + '">' + grab(b.id) + '</div>';
252
+ return '<div' + marker(b) + visClass(b.props) + ' style="' + boxCss(b.props) + '">' + grab(b.id) + '</div>';
216
253
  }).filter(Boolean).join('\n ') || '&nbsp;';
217
254
  const cells = r.cols.map((c) => {
218
255
  // Column-level styling (bg/radius/inner padding) renders as a wrapper
@@ -228,9 +265,12 @@ export function buildHtml(state, root, boxCss) {
228
265
  // for every wide client.
229
266
  const wrapCls = [plan.cell ? 'mc-stack' : '', plan.row].filter(Boolean).join(' ');
230
267
  const stackWrap = wrapCls ? ' class="' + wrapCls + '"' : '';
268
+ const mcr = markers && rp.layout && rp.layout !== 'columns'
269
+ ? ' data-mcr="' + attrEsc(JSON.stringify({ layout: rp.layout, flexDir: rp.flexDir || 'row', justify: rp.justify || 'flex-start', alignItems: rp.alignItems || 'stretch', wrap: rp.wrap !== false, gridCols: rp.gridCols || 2, gap: rp.gap || 0, spans: r.cols.map((c) => c.span) })) + '"'
270
+ : '';
231
271
  const cssBody = rp.layout === 'grid'
232
- ? '<div' + stackWrap + ' style="display:grid;grid-template-columns:repeat(' + (rp.gridCols || 2) + ',minmax(0,1fr));gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div>' + colInner(c) + '</div>').join('\n ') + '\n </div>'
233
- : '<div' + stackWrap + ' style="display:flex;flex-direction:' + (rp.flexDir || 'row') + ';justify-content:' + (rp.justify || 'flex-start') + ';align-items:' + (rp.alignItems || 'stretch') + ';flex-wrap:' + (rp.wrap ? 'wrap' : 'nowrap') + ';gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div style="flex:' + c.span + ' 1 auto;min-width:0">' + colInner(c) + '</div>').join('\n ') + '\n </div>';
272
+ ? '<div' + mcr + stackWrap + ' style="display:grid;grid-template-columns:repeat(' + (rp.gridCols || 2) + ',minmax(0,1fr));gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div>' + colInner(c) + '</div>').join('\n ') + '\n </div>'
273
+ : '<div' + mcr + stackWrap + ' style="display:flex;flex-direction:' + (rp.flexDir || 'row') + ';justify-content:' + (rp.justify || 'flex-start') + ';align-items:' + (rp.alignItems || 'stretch') + ';flex-wrap:' + (rp.wrap ? 'wrap' : 'nowrap') + ';gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div style="flex:' + c.span + ' 1 auto;min-width:0">' + colInner(c) + '</div>').join('\n ') + '\n </div>';
234
274
  const body = rp.layout && rp.layout !== 'columns'
235
275
  ? cssBody
236
276
  // The `<tr>` is what becomes the flex container for two-up and reverse;
@@ -244,8 +284,21 @@ export function buildHtml(state, root, boxCss) {
244
284
  : 'background:' + (rp.bg || t.contentBg || 'transparent') + ';';
245
285
  const tdBorder = rowBorderCss(rp)
246
286
  + (rp.radius ? 'border-radius:' + rp.radius + 'px;' : '')
247
- + (rp.shadow ? 'box-shadow:' + rp.shadow + ';' : '')
248
- + ((rp.mt || rp.mr || rp.mb || rp.ml || rp.my) ? 'margin:' + rowMargin(rp) + ';' : '');
287
+ + (rp.shadow ? 'box-shadow:' + rp.shadow + ';' : '');
288
+ // Outside margins and Max width need an element mail clients actually
289
+ // honour them on: margins on a <td> are ignored by every major client
290
+ // (the old shape wrote them there -- inert in sent mail AND never read
291
+ // back), and max-width was canvas-only. Both now ride a wrapper div
292
+ // INSIDE the cell, carrying the row's paint and padding with them so
293
+ // the margin sits outside the painted box, exactly as the canvas draws
294
+ // it. Rows using neither keep the old markup byte for byte.
295
+ const boxed = rp.mt || rp.mr || rp.mb || rp.ml || rp.my || (rp.maxW && rp.maxW < 100);
296
+ if (boxed) {
297
+ const cap = rp.maxW && rp.maxW < 100 ? 'max-width:' + rp.maxW + '%;' : '';
298
+ // centerEmpty only when capped: an uncapped box spans the cell anyway,
299
+ // and `auto` there would only read back as a lost slider value.
300
+ return ' <tr>\n <td style="padding:0;">\n <div style="' + cap + 'margin:' + rowMargin(rp, !!cap) + ';padding:' + rowPad(rp) + ';' + tdBg + tdBorder + '">\n ' + body + '\n </div>\n </td>\n </tr>';
301
+ }
249
302
  return ' <tr>\n <td style="padding:' + rowPad(rp) + ';' + tdBg + tdBorder + '">\n ' + body + '\n </td>\n </tr>';
250
303
  }).join('\n') + (logic.tail ? '\n ' + logic.tail : '');
251
304
  // The page section -- the full-width area the content column sits on. Its
@@ -353,6 +406,26 @@ export function buildHtml(state, root, boxCss) {
353
406
  // `text-size-adjust` at 100%, never `none`: both stop a mobile client
354
407
  // inflating the type, but `none` also blocks legitimate scaling and leaves
355
408
  // text unreadably small on some Android clients.
409
+ /*
410
+ * theme.link, made real: mail clients have no stylesheet to inherit a link
411
+ * color from (Gmail strips <style>; the sheet rule that paints the canvas
412
+ * never ships), so the value must ride every anchor inline. Stamped only
413
+ * where no inline color already stands -- a button label, a menu item, or
414
+ * a link the user recolored by hand keeps its own -- and skipped entirely
415
+ * when the theme has no link color. The canvas strips restated stamps at
416
+ * render time (overrideLinkColor), which is what keeps a reloaded document
417
+ * inheriting: export -> import -> export re-stamps the same bytes.
418
+ */
419
+ const stampLinks = (html) => {
420
+ if (!t.link) return html;
421
+ return html.replace(/<a\b([^>]*)>/g, (m0, attrs) => {
422
+ if (/(?:[;"\s])color\s*:/.test(attrs)) return m0;
423
+ if (/style="/.test(attrs)) {
424
+ return '<a' + attrs.replace(/style="([^"]*)"/, (s0, css) => 'style="' + css + (!css.trim() || css.trim().endsWith(';') ? '' : ';') + 'color:' + t.link + ';"') + '>';
425
+ }
426
+ return '<a' + attrs + ' style="color:' + t.link + ';">';
427
+ });
428
+ };
356
429
  const bodyStyle = 'margin:0;padding:0;background:' + pageBg + ';font-family:' + t.font.replace(/"/g, "'") + ';color:' + t.text + ';-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;text-size-adjust:100%;';
357
- return msoHarden('<!doctype html>\n<html lang="en" xmlns:o="urn:schemas-microsoft-com:office:office">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<meta name="color-scheme" content="light">\n<meta name="supported-color-schemes" content="light">\n<title>' + 'Email' + '</title>' + msoHead + stackCss + '\n</head>\n<body style="' + bodyStyle + '">\n<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:' + pageBg + ';">\n <tr><td align="center" style="padding:' + pagePad + ';">\n ' + ghostOpen + '\n ' + shell + '\n ' + ghostClose + '\n </td></tr>\n</table>\n</body>\n</html>');
430
+ return msoHarden(stampLinks('<!doctype html>\n<html lang="en" xmlns:o="urn:schemas-microsoft-com:office:office">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<meta name="color-scheme" content="light">\n<meta name="supported-color-schemes" content="light">\n<title>' + 'Email' + '</title>' + msoHead + stackCss + '\n</head>\n<body style="' + bodyStyle + '">\n<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:' + pageBg + ';">\n <tr><td align="center" style="padding:' + pagePad + ';">\n ' + ghostOpen + '\n ' + shell + '\n ' + ghostClose + '\n </td></tr>\n</table>\n</body>\n</html>'));
358
431
  }
@@ -1,83 +1,83 @@
1
- import { EN as ENBase } from './en.js';
2
-
3
- /**
4
- * Builds a translator. `overrides` is whatever a host passes as `.messages`
5
- * on the element -- a host's own table, an imported locale, or both merged
6
- * via `defineMessages` below.
7
- *
8
- * Three deliberate properties:
9
- * 1. English always resolves. A locale is an overlay, never a replacement,
10
- * so a partial or missing translation shows English rather than a gap.
11
- * 2. Params interpolate `{name}`.
12
- * 3. A truly missing key (not in `overrides`, not in `EN`) renders as the
13
- * key itself, not an empty string -- a visible `toast.deleted` in the UI
14
- * is obviously wrong and names the exact key to add, where blank text
15
- * just looks like a broken build.
16
- */
17
- export function createTranslator(overrides) {
18
- const table = overrides || {};
19
- return function t(key, params) {
20
- const template = table[key] ?? ENBase[key] ?? key;
21
- if (!params) return template;
22
- return template.replace(/\{(\w+)\}/g, (whole, name) => (name in params ? String(params[name]) : whole));
23
- };
24
- }
25
-
26
- /** Merges a locale over a base, for a host assembling its own table -- the documented way to combine a shipped locale with a few product-specific overrides. */
27
- export function defineMessages(base, overrides) {
28
- return Object.assign({}, base, overrides);
29
- }
30
-
31
- /** Keys in `base` that `locale` does not translate. What a translator has left to do. */
32
- export function missingKeys(locale, base) {
33
- const source = base || ENBase;
34
- return Object.keys(source).filter((key) => locale[key] === undefined).sort();
35
- }
36
-
37
- /**
38
- * Every locale that ships, for a host building a language switcher.
39
- * Metadata only -- no message tables -- so listing the locales never pulls
40
- * every translation file into a consumer's bundle; a host deep-imports the
41
- * one it wants, e.g. `mailcraft-editor/src/core/i18n/bn.js`.
42
- */
43
- export const LOCALES = [
44
- { tag: 'en', name: 'English' },
45
- { tag: 'ar', name: 'Arabic', rtl: true },
46
- { tag: 'bn', name: 'Bangla' },
47
- { tag: 'dz', name: 'Dzongkha' },
48
- { tag: 'bg', name: 'Bulgarian' },
49
- { tag: 'ca', name: 'Catalan' },
50
- { tag: 'cs', name: 'Czech' },
51
- { tag: 'da', name: 'Danish' },
52
- { tag: 'de', name: 'German' },
53
- { tag: 'de-CH', name: 'Swiss German' },
54
- { tag: 'el', name: 'Greek' },
55
- { tag: 'es', name: 'Spanish' },
56
- { tag: 'et', name: 'Estonian' },
57
- { tag: 'fi', name: 'Finnish' },
58
- { tag: 'fr', name: 'French' },
59
- { tag: 'hr', name: 'Croatian' },
60
- { tag: 'hu', name: 'Hungarian' },
61
- { tag: 'it', name: 'Italian' },
62
- { tag: 'lt', name: 'Lithuanian' },
63
- { tag: 'lv', name: 'Latvian' },
64
- { tag: 'nb', name: 'Norwegian Bokmål' },
65
- { tag: 'nl', name: 'Dutch' },
66
- { tag: 'pl', name: 'Polish' },
67
- { tag: 'pt', name: 'Portuguese' },
68
- { tag: 'ro', name: 'Romanian' },
69
- { tag: 'ru', name: 'Russian' },
70
- { tag: 'sk', name: 'Slovak' },
71
- { tag: 'sl', name: 'Slovenian' },
72
- { tag: 'sv', name: 'Swedish' },
73
- { tag: 'tr', name: 'Turkish' },
74
- { tag: 'uk', name: 'Ukrainian' },
75
- ];
76
-
77
- /** `true` when the tag is written right to left. Metadata only -- `dir` is still what actually flips the layout. */
78
- export function isRtl(tag) {
79
- const entry = LOCALES.find((l) => l.tag === tag);
80
- return entry ? entry.rtl === true : false;
81
- }
82
-
83
- export { EN, MESSAGE_KEYS } from './en.js';
1
+ import { EN as ENBase } from './en.js';
2
+
3
+ /**
4
+ * Builds a translator. `overrides` is whatever a host passes as `.messages`
5
+ * on the element -- a host's own table, an imported locale, or both merged
6
+ * via `defineMessages` below.
7
+ *
8
+ * Three deliberate properties:
9
+ * 1. English always resolves. A locale is an overlay, never a replacement,
10
+ * so a partial or missing translation shows English rather than a gap.
11
+ * 2. Params interpolate `{name}`.
12
+ * 3. A truly missing key (not in `overrides`, not in `EN`) renders as the
13
+ * key itself, not an empty string -- a visible `toast.deleted` in the UI
14
+ * is obviously wrong and names the exact key to add, where blank text
15
+ * just looks like a broken build.
16
+ */
17
+ export function createTranslator(overrides) {
18
+ const table = overrides || {};
19
+ return function t(key, params) {
20
+ const template = table[key] ?? ENBase[key] ?? key;
21
+ if (!params) return template;
22
+ return template.replace(/\{(\w+)\}/g, (whole, name) => (name in params ? String(params[name]) : whole));
23
+ };
24
+ }
25
+
26
+ /** Merges a locale over a base, for a host assembling its own table -- the documented way to combine a shipped locale with a few product-specific overrides. */
27
+ export function defineMessages(base, overrides) {
28
+ return Object.assign({}, base, overrides);
29
+ }
30
+
31
+ /** Keys in `base` that `locale` does not translate. What a translator has left to do. */
32
+ export function missingKeys(locale, base) {
33
+ const source = base || ENBase;
34
+ return Object.keys(source).filter((key) => locale[key] === undefined).sort();
35
+ }
36
+
37
+ /**
38
+ * Every locale that ships, for a host building a language switcher.
39
+ * Metadata only -- no message tables -- so listing the locales never pulls
40
+ * every translation file into a consumer's bundle; a host deep-imports the
41
+ * one it wants, e.g. `mailcraft-editor/src/core/i18n/bn.js`.
42
+ */
43
+ export const LOCALES = [
44
+ { tag: 'en', name: 'English' },
45
+ { tag: 'ar', name: 'Arabic', rtl: true },
46
+ { tag: 'bn', name: 'Bangla' },
47
+ { tag: 'dz', name: 'Dzongkha' },
48
+ { tag: 'bg', name: 'Bulgarian' },
49
+ { tag: 'ca', name: 'Catalan' },
50
+ { tag: 'cs', name: 'Czech' },
51
+ { tag: 'da', name: 'Danish' },
52
+ { tag: 'de', name: 'German' },
53
+ { tag: 'de-CH', name: 'Swiss German' },
54
+ { tag: 'el', name: 'Greek' },
55
+ { tag: 'es', name: 'Spanish' },
56
+ { tag: 'et', name: 'Estonian' },
57
+ { tag: 'fi', name: 'Finnish' },
58
+ { tag: 'fr', name: 'French' },
59
+ { tag: 'hr', name: 'Croatian' },
60
+ { tag: 'hu', name: 'Hungarian' },
61
+ { tag: 'it', name: 'Italian' },
62
+ { tag: 'lt', name: 'Lithuanian' },
63
+ { tag: 'lv', name: 'Latvian' },
64
+ { tag: 'nb', name: 'Norwegian Bokmål' },
65
+ { tag: 'nl', name: 'Dutch' },
66
+ { tag: 'pl', name: 'Polish' },
67
+ { tag: 'pt', name: 'Portuguese' },
68
+ { tag: 'ro', name: 'Romanian' },
69
+ { tag: 'ru', name: 'Russian' },
70
+ { tag: 'sk', name: 'Slovak' },
71
+ { tag: 'sl', name: 'Slovenian' },
72
+ { tag: 'sv', name: 'Swedish' },
73
+ { tag: 'tr', name: 'Turkish' },
74
+ { tag: 'uk', name: 'Ukrainian' },
75
+ ];
76
+
77
+ /** `true` when the tag is written right to left. Metadata only -- `dir` is still what actually flips the layout. */
78
+ export function isRtl(tag) {
79
+ const entry = LOCALES.find((l) => l.tag === tag);
80
+ return entry ? entry.rtl === true : false;
81
+ }
82
+
83
+ export { EN, MESSAGE_KEYS } from './en.js';
package/src/core/icons.js CHANGED
@@ -67,7 +67,6 @@ const ICONS = {
67
67
  hero: [['rect', 3, 4, 18, 16], 'M6 11h12', 'M6 15h6', ['rect', 14.5, 13.5, 4, 3]],
68
68
  stats: ['M5 19v-7', 'M12 19V5', 'M19 19v-4', 'M3 19h18'],
69
69
  gallery: [['rect', 3, 4, 7.5, 7.5], ['rect', 13.5, 4, 7.5, 7.5], ['rect', 3, 12.5, 7.5, 7.5], ['rect', 13.5, 12.5, 7.5, 7.5]],
70
- embed: [['rect', 2.5, 5, 19, 14], 'M2.5 9h19', 'M10.5 11.5l3.5 2-3.5 2z'],
71
70
  css: ['M5 4h14l-1.5 15L12 21l-5.5-2z', 'M8.5 9h7', 'M9 13h5'],
72
71
  codeblock: [['rect', 3, 4, 18, 16], 'M9.5 10l-2 2 2 2', 'M14.5 10l2 2-2 2'],
73
72
  data: ['M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3z', 'M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6', 'M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3'],
package/src/core/ids.js CHANGED
@@ -1 +1 @@
1
- export const uid = () => Math.random().toString(36).slice(2, 9);
1
+ export const uid = () => Math.random().toString(36).slice(2, 9);