@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.
- package/CHANGELOG.md +99 -0
- package/DOCS.md +27 -27
- package/README.md +1 -1
- package/README.md.txt +1 -1
- package/dist/mailcraft-editor.bundle.js +76 -48
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +2 -1
- package/src/core/blocks.js +15 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +88 -6
- package/src/core/export.js +195 -6
- package/src/core/i18n/index.js +83 -83
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +482 -31
- package/src/core/layout-style.js +100 -100
- package/src/core/parse.js +10 -10
- package/src/core/placeholder.js +15 -15
- package/src/core/sanitize.js +69 -1
- package/src/core/variables.js +11 -11
- package/src/mailcraft-editor.js +41 -2
- package/src/render/block-body.js +109 -12
- package/src/render/canvas.js +45 -3
- package/src/render/fields.js +28 -2
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +6 -0
- package/src/render/story.js +415 -415
package/src/core/export.js
CHANGED
|
@@ -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 ') || ' ';
|
|
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
|
-
|
|
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("' + cssUrl(rp.bgImage) + '");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
|
-
|
|
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
|
}
|
package/src/core/i18n/index.js
CHANGED
|
@@ -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);
|