@seliseblocks/mailcraft 0.2.7 → 0.2.9

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.
@@ -48,6 +48,57 @@ function logicPlan(doc) {
48
48
  return { emit, tail };
49
49
  }
50
50
 
51
+ /**
52
+ * The two Word-engine declarations that have to be applied to the finished
53
+ * markup rather than to the DOM the renderer builds.
54
+ *
55
+ * `mso-*` are not real CSS properties, and CSSOM silently drops anything it
56
+ * does not recognise -- `style.msoLineHeightRule = 'exactly'` and
57
+ * `setProperty('mso-line-height-rule', ...)` both no-op, and the canvas is
58
+ * built through `Object.assign(node.style, ...)`. Since the exporter reads
59
+ * that DOM back as `outerHTML`, a string pass over the result is the only
60
+ * place these can be added. They are also meaningless anywhere but Outlook,
61
+ * so the canvas is better off without them.
62
+ *
63
+ * - `mso-line-height-rule:exactly` -- Classic Outlook otherwise ignores
64
+ * `line-height` outright and sets text solid. Added only where a real
65
+ * line-height is already declared, so it never invents spacing of its own.
66
+ * - `mso-table-lspace/rspace:0pt` -- Word adds its own horizontal space
67
+ * around a table, which shows up as phantom gaps between columns.
68
+ *
69
+ * Both are idempotent: a second pass finds its own marker and skips.
70
+ */
71
+ export function msoHarden(html) {
72
+ return html
73
+ .replace(/style="([^"]*line-height:[^"]*)"/g, (m0, css) => {
74
+ if (/mso-line-height-rule/.test(css)) return m0;
75
+ // A ratio resolved against the font size in the same declaration.
76
+ // `exactly` tells Word to use the line-height verbatim, and a unitless
77
+ // 1.65 is not a length -- the pair is ambiguous at best and collapses
78
+ // the leading at worst. Every block that sets a line-height sets its
79
+ // font-size beside it, so the multiplication is exact rather than a
80
+ // guess; anything the renderer did not author (imported markup with a
81
+ // bare ratio, or a keyword like `normal`) is left exactly as it is and
82
+ // gets no `exactly` either, since there would be no length to honour.
83
+ const fs = css.match(/font-size:\s*([\d.]+)px/);
84
+ const out = fs
85
+ ? css.replace(/line-height:\s*([\d.]+)\s*(;|$)/g, (s0, ratio, end) => 'line-height:' + Math.round(parseFloat(fs[1]) * parseFloat(ratio)) + 'px' + end)
86
+ : css;
87
+ if (!/line-height:\s*[\d.]+px/.test(out)) return 'style="' + out + '"';
88
+ return 'style="' + out + (out.trim().endsWith(';') ? '' : ';') + 'mso-line-height-rule:exactly;"';
89
+ })
90
+ .replace(/<table\b([^>]*)>/g, (m0, attrs) => {
91
+ if (/mso-table-lspace/.test(attrs)) return m0;
92
+ const spacing = 'mso-table-lspace:0pt;mso-table-rspace:0pt;';
93
+ // Appended, never prepended: the declarations the template actually
94
+ // authored stay at the front of the attribute, where both the importer
95
+ // and a human reading the source expect to find them.
96
+ return /style="/.test(attrs)
97
+ ? '<table' + attrs.replace(/style="([^"]*)"/, (s0, css) => 'style="' + css + (!css.trim() || css.trim().endsWith(';') ? '' : ';') + spacing + '"') + '>'
98
+ : '<table' + attrs + ' style="' + spacing + '">';
99
+ });
100
+ }
101
+
51
102
  /**
52
103
  * Display-only dressing for the Code modal's live-preview iframe: the
53
104
  * exported template carries literal {{#if}}/{{#each}} tags, which an iframe
@@ -102,8 +153,50 @@ export function buildHtml(state, root, boxCss) {
102
153
  .replace(/\sdata-(?:gramm|gramm_editor|enable-grammarly|lt-active)="[^"]*"/g, '')
103
154
  .replace(/\sdraggable="[^"]*"/g, '');
104
155
  };
156
+ /*
157
+ * Which mobile rules this document actually needs. Only the ones a row (or
158
+ * a block) asks for are emitted, and rows that ask for the same thing share
159
+ * one class -- so a template that never leaves the defaults ships exactly
160
+ * the stylesheet it shipped before this feature existed, and one that uses
161
+ * every mode still ships a handful of rules rather than BEE's one selector
162
+ * per block with no deduplication.
163
+ */
164
+ const need = { stack: false, twoUp: false, reverse: false, hideM: false, hideD: false };
165
+ /**
166
+ * One decision per row, made once: what goes on the row box (`<tr>`, or the
167
+ * flex/grid wrapper) and what goes on each cell.
168
+ *
169
+ * The plain one-up stack keeps the exact markup and rule it had before this
170
+ * feature -- `mc-col` on the cells, no flex anywhere -- so the overwhelmingly
171
+ * common case cannot regress, and if a sanitiser ever strips `display:flex`
172
+ * the fancy modes degrade to the desktop layout rather than taking ordinary
173
+ * stacking down with them.
174
+ */
175
+ const mobilePlan = (rp, cols) => {
176
+ const mode = rp.mobileCols === undefined ? 1 : rp.mobileCols;
177
+ if (cols < 2 || mode === 'keep') return { row: '', cell: '' };
178
+ const rowCls = [];
179
+ // Two-up and reverse both need the row to become a flex container, which
180
+ // is safe precisely because it only ever runs inside the media query: a
181
+ // client that cannot do flex is a client that ignored the query and is
182
+ // still being shown the desktop table.
183
+ if (String(mode) === '2') { rowCls.push('mc-2up'); need.twoUp = true; }
184
+ if (rp.mobileOrder === 'reverse') { rowCls.push('mc-rev'); need.reverse = true; }
185
+ // Cells only carry `mc-col` in one-up; in two-up the row's own rule sizes
186
+ // them, so the two never fight over width.
187
+ const cell = String(mode) === '2' ? '' : 'mc-col';
188
+ if (cell) need.stack = true;
189
+ return { row: rowCls.join(' '), cell };
190
+ };
191
+ /** Per-block device visibility. Absent means "all devices", so nothing is emitted. */
192
+ const visClass = (bp) => {
193
+ if (bp.vis === 'desktop') { need.hideM = true; return ' class="mc-only-d"'; }
194
+ if (bp.vis === 'mobile') { need.hideD = true; return ' class="mc-only-m"'; }
195
+ return '';
196
+ };
105
197
  const rows = d.rows.map((r) => {
106
198
  const rp = r.props;
199
+ const plan = mobilePlan(rp, r.cols.length);
107
200
  // A row holding nothing but logic markers exists to wrap the *sections*
108
201
  // around it (drop a Condition onto the canvas above and below a group of
109
202
  // rows). Emitting its <tr> scaffolding would leave an empty padded band
@@ -119,7 +212,7 @@ export function buildHtml(state, root, boxCss) {
119
212
  if (b.type === 'html') return b.props.code || '';
120
213
  if (b.type === 'svg') return '<div style="text-align:' + b.props.align + ';padding:' + b.props.py + 'px 0">' + (b.props.code || '') + '</div>';
121
214
  if (b.type === 'condition' || b.type === 'loop') return logic.emit.get(b.id) || '';
122
- return '<div style="' + boxCss(b.props) + '">' + grab(b.id) + '</div>';
215
+ return '<div' + visClass(b.props) + ' style="' + boxCss(b.props) + '">' + grab(b.id) + '</div>';
123
216
  }).filter(Boolean).join('\n ') || '&nbsp;';
124
217
  const cells = r.cols.map((c) => {
125
218
  // Column-level styling (bg/radius/inner padding) renders as a wrapper
@@ -128,14 +221,21 @@ export function buildHtml(state, root, boxCss) {
128
221
  const inner = (c.bg || c.border || c.radius || c.padY || c.padX)
129
222
  ? '<div style="background:' + (c.bg || 'transparent') + ';' + (c.border ? 'border:' + c.border + 'px ' + (c.borderStyle || 'solid') + ' ' + (c.lineColor || '#e2e2e5') + ';' : '') + 'border-radius:' + (c.radius || 0) + 'px;padding:' + (c.padY || 0) + 'px ' + (c.padX || 0) + 'px">\n ' + colInner(c) + '\n </div>'
130
223
  : colInner(c);
131
- return '<td width="' + c.span + '%" valign="' + rp.valign + '" style="padding:0 ' + Math.round(rp.gap / 2) + 'px;">\n ' + inner + '\n </td>';
224
+ return '<td' + (plan.cell ? ' class="' + plan.cell + '"' : '') + ' width="' + c.span + '%" valign="' + rp.valign + '" style="padding:0 ' + Math.round(rp.gap / 2) + 'px;">\n ' + inner + '\n </td>';
132
225
  }).join('\n ');
226
+ // The CSS-layout rows reach the same behaviour through their wrapper: one
227
+ // class on the flex/grid container, so the markup stays exactly as it was
228
+ // for every wide client.
229
+ const wrapCls = [plan.cell ? 'mc-stack' : '', plan.row].filter(Boolean).join(' ');
230
+ const stackWrap = wrapCls ? ' class="' + wrapCls + '"' : '';
133
231
  const cssBody = rp.layout === 'grid'
134
- ? '<div 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>'
135
- : '<div 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>';
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>';
136
234
  const body = rp.layout && rp.layout !== 'columns'
137
235
  ? cssBody
138
- : '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>\n ' + cells + '\n </tr></table>';
236
+ // The `<tr>` is what becomes the flex container for two-up and reverse;
237
+ // in the default one-up stack it carries no class at all, exactly as before.
238
+ : '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr' + (plan.row ? ' class="' + plan.row + '"' : '') + '>\n ' + cells + '\n </tr></table>';
139
239
  const tdBg = rp.bgImage
140
240
  ? 'background-color:' + (rp.bg || t.contentBg || 'transparent') + ';background-image:' + (rp.overlay ? 'linear-gradient(rgba(20,22,24,' + (rp.overlay / 100) + '),rgba(20,22,24,' + (rp.overlay / 100) + ')),' : '') + 'url(&quot;' + cssUrl(rp.bgImage) + '&quot;);background-size:' + (rp.bgSize || 'cover') + ';background-position:' + (rp.bgPos || 'center') + ';background-repeat:' + (rp.bgRepeat || 'no-repeat') + ';'
141
241
  // Falls all the way through to `transparent`: a row inherits the
@@ -165,5 +265,94 @@ export function buildHtml(state, root, boxCss) {
165
265
  // Patchy in mail clients (Outlook drops it), but honest: what the user
166
266
  // styled ships, and capable clients render it.
167
267
  + (t.shadow ? 'box-shadow:' + t.shadow + ';' : '');
168
- return '<!doctype html>\n<html lang="en">\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 dark">\n<title>' + 'Email' + '</title>\n</head>\n<body style="margin:0;padding:0;background:' + pageBg + ';font-family:' + t.font.replace(/"/g, "'") + ';color:' + t.text + ';-webkit-font-smoothing:antialiased;">\n<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:' + pageBg + ';">\n <tr><td align="center" style="padding:' + pagePad + ';">\n <table role="presentation" width="' + t.width + '" cellpadding="0" cellspacing="0" border="0" style="width:' + t.width + 'px;max-width:100%;background:' + (t.contentBg || 'transparent') + ';' + contentShape + '">\n' + rows + '\n </table>\n </td></tr>\n</table>\n</body>\n</html>';
268
+ /*
269
+ * The content column is `width:100%` capped by `max-width`, never a fixed
270
+ * `width:<n>px`.
271
+ *
272
+ * The fixed width was what made the sent email unresponsive, and not only
273
+ * for itself: a px width becomes the table's min-content contribution, so
274
+ * it propagated outward and pinned the full-width wrapper open too. A plain
275
+ * text-only template measured 620px of horizontal scroll on a 390px phone,
276
+ * and `max-width:100%` -- already sitting right there -- never got the
277
+ * chance to engage, because 100% of a container the table had itself forced
278
+ * to 620px is 620px. Swapping the two makes the cap the real constraint:
279
+ * measured 500px (fits) on the phone, unchanged 620px on the desktop.
280
+ *
281
+ * The `<!--[if mso]>` pair is the price of that. Word-based Outlook honours
282
+ * neither `max-width` nor the media query below, so on its own it would now
283
+ * render the column edge to edge across the whole window; the ghost table
284
+ * is a fixed-width cage only Outlook sees, which holds the old geometry for
285
+ * exactly the client that cannot do better. Every other client skips the
286
+ * conditional comment entirely and gets the fluid table.
287
+ */
288
+ const ghostOpen = '<!--[if mso]><table role="presentation" width="' + t.width + '" cellpadding="0" cellspacing="0" border="0"><tr><td><![endif]-->';
289
+ const ghostClose = '<!--[if mso]></td></tr></table><![endif]-->';
290
+ const shell = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:' + t.width + 'px;background:' + (t.contentBg || 'transparent') + ';' + contentShape + '">\n' + rows + '\n </table>';
291
+ /*
292
+ * The one embedded stylesheet in the document, and the only place the
293
+ * exporter is not inline-styled -- a media query cannot be expressed
294
+ * inline, and every rule here is a narrow-screen override of an inline
295
+ * style, hence `!important` throughout.
296
+ *
297
+ * `mc-col` / `mc-stack` are what finally make "Stack columns on mobile" do
298
+ * something: the toggle has shipped in the row inspector since columns
299
+ * existed, but nothing read the prop -- no media query was emitted and the
300
+ * canvas ignored it -- so a 4-column row stayed 4 columns of 60px on a
301
+ * phone. The image rule is the other half of being responsive: a fixed-width
302
+ * image from an import would otherwise hold a stacked column open.
303
+ */
304
+ const stackCss = '\n<style>\n'
305
+ + '/* Apple Mail and iOS auto-detect dates, addresses and phone numbers and\n'
306
+ + ' repaint them as blue underlined links. This hands them back to the\n'
307
+ + ' surrounding text; links the template actually declares are untouched,\n'
308
+ + ' because they carry their own inline colour. */\n'
309
+ + 'a[x-apple-data-detectors] { color:inherit !important; text-decoration:none !important; }\n'
310
+ /*
311
+ * No `p { line-height: inherit }` here, though it is standard in
312
+ * hand-written email and BEE emits it. This document is also an *input*:
313
+ * core/css-cascade.js folds every non-`@media` rule into inline styles on
314
+ * import, so that one rule came back stamped on every paragraph and an
315
+ * export -> import -> export cycle stopped converging. The pixel
316
+ * line-heights it would have protected are already inline on the block
317
+ * that owns them, which is the stronger guarantee anyway.
318
+ */
319
+ // A mobile-only block has to be hidden here, outside the query, because
320
+ // Classic Outlook never reads the query -- `display:none` alone would
321
+ // leave it visible in exactly the client that cannot be told otherwise.
322
+ // `mso-hide` is the half Word understands; the rest is for everyone else.
323
+ + (need.hideD ? '.mc-only-m, .mc-only-m table { mso-hide:all; display:none; max-height:0; overflow:hidden; }\n' : '')
324
+ + '@media only screen and (max-width:' + t.width + 'px) {\n'
325
+ + (need.stack ? ' .mc-col { display:block !important; width:100% !important; padding-left:0 !important; padding-right:0 !important; }\n'
326
+ + ' .mc-stack { display:block !important; }\n'
327
+ + ' .mc-stack > div { width:100% !important; }\n' : '')
328
+ // Two-up: the row becomes a flex container and each cell takes half.
329
+ // `box-sizing` is load-bearing -- with the default content box, a cell's
330
+ // own padding pushes 50% over the line and every cell wraps to its own row.
331
+ + (need.twoUp ? ' .mc-2up { display:flex !important; flex-wrap:wrap !important; }\n'
332
+ + ' .mc-2up > td, .mc-2up > div { box-sizing:border-box !important; flex:0 0 50% !important; max-width:50% !important; }\n' : '')
333
+ // Reverse: `column-reverse` flips a stack of any depth, where the usual
334
+ // `table-header-group` trick has only two usable slots and so cannot
335
+ // reverse a three- or four-column row at all. Two-up reverses along both
336
+ // axes so the last cell ends up first.
337
+ + (need.reverse ? ' .mc-rev { display:flex !important; flex-wrap:wrap !important; flex-direction:column-reverse !important; }\n'
338
+ + ' .mc-2up.mc-rev { flex-direction:row-reverse !important; flex-wrap:wrap-reverse !important; }\n' : '')
339
+ + (need.hideM ? ' .mc-only-d, .mc-only-d table { display:none !important; max-height:0 !important; overflow:hidden !important; }\n' : '')
340
+ + (need.hideD ? ' .mc-only-m, .mc-only-m table { display:block !important; max-height:none !important; overflow:visible !important; }\n' : '')
341
+ + ' img { max-width:100% !important; height:auto !important; }\n'
342
+ + '}\n</style>';
343
+ /*
344
+ * `xmlns:o` earns its place; `xmlns:v` does not. The Office namespace is
345
+ * what makes `<o:OfficeDocumentSettings>` parse, and `PixelsPerInch` 96 is
346
+ * a live bug fix rather than a legacy one: on a high-DPI Windows display
347
+ * Outlook renders at 120dpi and scales the whole template about 25% larger
348
+ * than authored. VML's namespace is deliberately absent -- nothing here
349
+ * emits VML, and declaring a namespace for markup that never appears is
350
+ * noise in every other client.
351
+ */
352
+ const msoHead = '\n<!--[if mso]>\n<xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>\n<![endif]-->';
353
+ // `text-size-adjust` at 100%, never `none`: both stop a mobile client
354
+ // inflating the type, but `none` also blocks legitimate scaling and leaves
355
+ // text unreadably small on some Android clients.
356
+ 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>');
169
358
  }
@@ -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/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);