@seliseblocks/mailcraft 0.2.8 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seliseblocks/mailcraft",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "Framework-agnostic drag-and-drop email template editor, packaged as a zero-dependency Web Component.",
5
5
  "license": "MIT",
6
6
  "author": "SELISE Digital Platforms",
@@ -49,6 +49,7 @@
49
49
  "examples",
50
50
  "README.md",
51
51
  "DOCS.md",
52
+ "CHANGELOG.md",
52
53
  "LICENSE"
53
54
  ],
54
55
  "engines": {
@@ -1,117 +1,117 @@
1
- /**
2
- * Import-time CSS cascade: folds a parsed document's `<style>` rules into the
3
- * elements' inline styles, so the importer's classifiers (which read inline
4
- * styles only) see class-styled templates -- Mailchimp exports, hand-written
5
- * emails, framework output that was never inlined -- the same way a mail
6
- * client would.
7
- *
8
- * Deliberately a subset of a real cascade, matched to what email CSS uses:
9
- * - `@media` blocks (and every other at-rule) are dropped whole: responsive
10
- * overrides can't be represented in the imported model, desktop values win.
11
- * A side benefit: base-rule `.desktop_hide { display:none }` still applies,
12
- * so mobile-only duplicate content is correctly dropped by the importer's
13
- * hidden-element skip.
14
- * - Selectors containing `:` are skipped -- pseudo-classes/-elements are
15
- * interactive or generated state with no place in a static import.
16
- * - Specificity is the classic ids/classes/tags count; equal specificity
17
- * resolves by source order; `!important` wins over everything including
18
- * inline styles, which otherwise always win (matching browser behavior for
19
- * the combinations that matter here).
20
- */
21
-
22
- function stripComments(css) {
23
- return String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
24
- }
25
-
26
- /** Removes every at-rule: block-less ones (`@import ...;`) to the semicolon, block ones (`@media { ... }`) across balanced braces. */
27
- function stripAtRules(css) {
28
- let out = '';
29
- let i = 0;
30
- while (i < css.length) {
31
- if (css[i] === '@') {
32
- const semi = css.indexOf(';', i);
33
- const brace = css.indexOf('{', i);
34
- if (brace === -1 || (semi !== -1 && semi < brace)) {
35
- i = semi === -1 ? css.length : semi + 1;
36
- continue;
37
- }
38
- let depth = 0;
39
- let j = brace;
40
- for (; j < css.length; j++) {
41
- if (css[j] === '{') depth++;
42
- else if (css[j] === '}') { depth--; if (!depth) break; }
43
- }
44
- i = j + 1;
45
- continue;
46
- }
47
- out += css[i];
48
- i++;
49
- }
50
- return out;
51
- }
52
-
53
- function specificity(sel) {
54
- const ids = (sel.match(/#[\w-]+/g) || []).length;
55
- const classes = (sel.match(/\.[\w-]+|\[[^\]]*\]/g) || []).length;
56
- const tags = (sel.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
57
- return ids * 100 + classes * 10 + tags;
58
- }
59
-
60
- export function inlineStylesheets(doc) {
61
- const sheets = Array.from(doc.querySelectorAll('style'));
62
- if (!sheets.length) return;
63
- const css = stripAtRules(stripComments(sheets.map((s) => s.textContent || '').join('\n')));
64
-
65
- const rules = [];
66
- const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
67
- let m;
68
- let order = 0;
69
- while ((m = ruleRe.exec(css))) {
70
- const decls = [];
71
- m[2].split(';').forEach((d) => {
72
- const at = d.indexOf(':');
73
- if (at < 0) return;
74
- const prop = d.slice(0, at).trim().toLowerCase();
75
- let value = d.slice(at + 1).trim();
76
- if (!prop || !value || prop.indexOf('--') === 0 || prop.indexOf('mso-') === 0) return;
77
- const important = /!important\s*$/i.test(value);
78
- if (important) value = value.replace(/!important\s*$/i, '').trim();
79
- if (value) decls.push({ prop, value, important });
80
- });
81
- if (!decls.length) continue;
82
- m[1].split(',').forEach((raw) => {
83
- const sel = raw.trim();
84
- if (!sel || sel === '*' || sel.indexOf(':') > -1) return;
85
- rules.push({ sel, decls, spec: specificity(sel), order: order++ });
86
- });
87
- }
88
- if (!rules.length) return;
89
-
90
- // Ascending: later (more specific / later-in-source) rules overwrite
91
- // earlier winners per property below.
92
- rules.sort((a, b) => a.spec - b.spec || a.order - b.order);
93
-
94
- const winners = new Map(); // element -> Map(prop -> {value, important})
95
- rules.forEach((rule) => {
96
- let matched;
97
- try { matched = doc.querySelectorAll(rule.sel); } catch { return; }
98
- matched.forEach((el) => {
99
- if (el !== doc.body && !doc.body.contains(el)) return;
100
- let bucket = winners.get(el);
101
- if (!bucket) { bucket = new Map(); winners.set(el, bucket); }
102
- rule.decls.forEach((d) => {
103
- const cur = bucket.get(d.prop);
104
- if (cur && cur.important && !d.important) return;
105
- bucket.set(d.prop, d);
106
- });
107
- });
108
- });
109
-
110
- winners.forEach((bucket, el) => {
111
- if (!el.style) return;
112
- bucket.forEach((d, prop) => {
113
- if (!d.important && el.style.getPropertyValue(prop)) return; // inline wins
114
- try { el.style.setProperty(prop, d.value); } catch { /* unparseable value -- skip */ }
115
- });
116
- });
117
- }
1
+ /**
2
+ * Import-time CSS cascade: folds a parsed document's `<style>` rules into the
3
+ * elements' inline styles, so the importer's classifiers (which read inline
4
+ * styles only) see class-styled templates -- Mailchimp exports, hand-written
5
+ * emails, framework output that was never inlined -- the same way a mail
6
+ * client would.
7
+ *
8
+ * Deliberately a subset of a real cascade, matched to what email CSS uses:
9
+ * - `@media` blocks (and every other at-rule) are dropped whole: responsive
10
+ * overrides can't be represented in the imported model, desktop values win.
11
+ * A side benefit: base-rule `.desktop_hide { display:none }` still applies,
12
+ * so mobile-only duplicate content is correctly dropped by the importer's
13
+ * hidden-element skip.
14
+ * - Selectors containing `:` are skipped -- pseudo-classes/-elements are
15
+ * interactive or generated state with no place in a static import.
16
+ * - Specificity is the classic ids/classes/tags count; equal specificity
17
+ * resolves by source order; `!important` wins over everything including
18
+ * inline styles, which otherwise always win (matching browser behavior for
19
+ * the combinations that matter here).
20
+ */
21
+
22
+ function stripComments(css) {
23
+ return String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
24
+ }
25
+
26
+ /** Removes every at-rule: block-less ones (`@import ...;`) to the semicolon, block ones (`@media { ... }`) across balanced braces. */
27
+ function stripAtRules(css) {
28
+ let out = '';
29
+ let i = 0;
30
+ while (i < css.length) {
31
+ if (css[i] === '@') {
32
+ const semi = css.indexOf(';', i);
33
+ const brace = css.indexOf('{', i);
34
+ if (brace === -1 || (semi !== -1 && semi < brace)) {
35
+ i = semi === -1 ? css.length : semi + 1;
36
+ continue;
37
+ }
38
+ let depth = 0;
39
+ let j = brace;
40
+ for (; j < css.length; j++) {
41
+ if (css[j] === '{') depth++;
42
+ else if (css[j] === '}') { depth--; if (!depth) break; }
43
+ }
44
+ i = j + 1;
45
+ continue;
46
+ }
47
+ out += css[i];
48
+ i++;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function specificity(sel) {
54
+ const ids = (sel.match(/#[\w-]+/g) || []).length;
55
+ const classes = (sel.match(/\.[\w-]+|\[[^\]]*\]/g) || []).length;
56
+ const tags = (sel.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
57
+ return ids * 100 + classes * 10 + tags;
58
+ }
59
+
60
+ export function inlineStylesheets(doc) {
61
+ const sheets = Array.from(doc.querySelectorAll('style'));
62
+ if (!sheets.length) return;
63
+ const css = stripAtRules(stripComments(sheets.map((s) => s.textContent || '').join('\n')));
64
+
65
+ const rules = [];
66
+ const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
67
+ let m;
68
+ let order = 0;
69
+ while ((m = ruleRe.exec(css))) {
70
+ const decls = [];
71
+ m[2].split(';').forEach((d) => {
72
+ const at = d.indexOf(':');
73
+ if (at < 0) return;
74
+ const prop = d.slice(0, at).trim().toLowerCase();
75
+ let value = d.slice(at + 1).trim();
76
+ if (!prop || !value || prop.indexOf('--') === 0 || prop.indexOf('mso-') === 0) return;
77
+ const important = /!important\s*$/i.test(value);
78
+ if (important) value = value.replace(/!important\s*$/i, '').trim();
79
+ if (value) decls.push({ prop, value, important });
80
+ });
81
+ if (!decls.length) continue;
82
+ m[1].split(',').forEach((raw) => {
83
+ const sel = raw.trim();
84
+ if (!sel || sel === '*' || sel.indexOf(':') > -1) return;
85
+ rules.push({ sel, decls, spec: specificity(sel), order: order++ });
86
+ });
87
+ }
88
+ if (!rules.length) return;
89
+
90
+ // Ascending: later (more specific / later-in-source) rules overwrite
91
+ // earlier winners per property below.
92
+ rules.sort((a, b) => a.spec - b.spec || a.order - b.order);
93
+
94
+ const winners = new Map(); // element -> Map(prop -> {value, important})
95
+ rules.forEach((rule) => {
96
+ let matched;
97
+ try { matched = doc.querySelectorAll(rule.sel); } catch { return; }
98
+ matched.forEach((el) => {
99
+ if (el !== doc.body && !doc.body.contains(el)) return;
100
+ let bucket = winners.get(el);
101
+ if (!bucket) { bucket = new Map(); winners.set(el, bucket); }
102
+ rule.decls.forEach((d) => {
103
+ const cur = bucket.get(d.prop);
104
+ if (cur && cur.important && !d.important) return;
105
+ bucket.set(d.prop, d);
106
+ });
107
+ });
108
+ });
109
+
110
+ winners.forEach((bucket, el) => {
111
+ if (!el.style) return;
112
+ bucket.forEach((d, prop) => {
113
+ if (!d.important && el.style.getPropertyValue(prop)) return; // inline wins
114
+ try { el.style.setProperty(prop, d.value); } catch { /* unparseable value -- skip */ }
115
+ });
116
+ });
117
+ }
@@ -3,7 +3,7 @@ import { mk, blk, mkRow, GROUPS, LAYOUTS, migrateDoc, normalizeDoc, blankDoc } f
3
3
  import { binder, decorate, group } from './binder.js';
4
4
  import { ALL_FOLDER_ID, normalizeAsset, resolveLimits, providerProblems } from './storage.js';
5
5
  import { validateFiles, limitsProblem } from './storage-limits.js';
6
- import { migrateTokens, cleanHtml, escHtml, linkHref } from './sanitize.js';
6
+ import { migrateTokens, cleanHtml, escHtml, linkHref, scaleInlineSizes, stripInlineStyle } from './sanitize.js';
7
7
  import { buildHtml as buildHtmlFn } from './export.js';
8
8
  import { htmlToDoc } from './import-html.js';
9
9
  import { boxCss } from './layout-style.js';
@@ -35,6 +35,48 @@ const UPLOAD_CONCURRENCY = 3;
35
35
  */
36
36
  const RETIRED_SEED = /^data:image\/svg\+xml;utf8,.*%3Cpattern%20id%3D%22s%22/;
37
37
  const withoutRetiredSeeds = (assets) => (Array.isArray(assets) ? assets.filter((a) => !RETIRED_SEED.test(String((a && a.url) || ''))) : []);
38
+ /**
39
+ * The one span Text size moves in, per block type -- shared by the inspector
40
+ * range and the RTE's ± buttons. They used to disagree: the panel allowed
41
+ * 8–96 (text) and 12–120 (heading) while `size()` clamped to a private
42
+ * 10–64, so a single "Larger text" click on a 96px block *shrank* it to 64.
43
+ */
44
+ const SIZE_SPAN = { text: [8, 96], heading: [12, 120] };
45
+ /** The blocks whose content is rich HTML in props, and the prop it lives in. Only these need the descendant rewrites below. */
46
+ const RICH_HTML_PROP = { text: 'html', list: 'items' };
47
+ /** Inspector key -> the inline CSS property whose descendant copies mask it (see `stripInlineStyle`). */
48
+ const RICH_OWNED_STYLE = { color: 'color', lh: 'line-height', weight: 'font-weight', align: 'text-align' };
49
+
50
+ /**
51
+ * Keeps a rich block's descendants in agreement with the block-level control
52
+ * being moved. Imported HTML deliberately keeps per-element inline typography
53
+ * (sanitize.js `cleanImportHtml`), and a descendant's own declaration beats
54
+ * inheritance from the wrapper -- so without this, Text size / color / Line
55
+ * spacing / weight / Align silently did nothing on any imported (or
56
+ * AI-drafted) block. Size *scales* descendants so a 15/26/15px hierarchy
57
+ * survives as 45/78/45 instead of flattening; the others strip the descendant
58
+ * property, exactly the semantics the Font control already shipped with.
59
+ * Runs inside the same `commit` as the prop write: one undo step, and a
60
+ * document nobody touches is never rewritten.
61
+ */
62
+ function syncRichContent(block, key, val) {
63
+ const prop = RICH_HTML_PROP[block.type];
64
+ if (!prop) return;
65
+ // list items are one fragment per line; the rewrite must not run across the joins.
66
+ const perLine = (src, fn) => (prop === 'items' ? String(src).split('\n').map(fn).join('\n') : fn(String(src)));
67
+ const src = block.props[prop];
68
+ if (src == null || src === '') return;
69
+ if (key === 'size') {
70
+ const cur = Number(block.props.size);
71
+ const next = Number(val);
72
+ // An unknown base (imports that carried no readable size) can't be scaled
73
+ // against -- the first explicit size just establishes the base.
74
+ if (cur > 0 && next > 0 && next !== cur) block.props[prop] = perLine(src, (s) => scaleInlineSizes(s, next / cur));
75
+ } else if (RICH_OWNED_STYLE[key]) {
76
+ block.props[prop] = perLine(src, (s) => stripInlineStyle(s, RICH_OWNED_STYLE[key]));
77
+ }
78
+ }
79
+
38
80
  const BORDER_STYLES = [
39
81
  { value: 'solid', label: 'Solid' },
40
82
  { value: 'dashed', label: 'Dashed' },
@@ -524,7 +566,8 @@ export class EditorCore {
524
566
  if (b.props[key] !== val) this.setProp(b.id, key, val);
525
567
  }
526
568
  const cur = Number(b.props.size) || 16;
527
- this.setProp(b.id, 'size', Math.max(10, Math.min(64, cur + delta)));
569
+ const [lo, hi] = SIZE_SPAN[b.type] || [10, 64];
570
+ this.setProp(b.id, 'size', Math.max(lo, Math.min(hi, cur + delta)));
528
571
  }
529
572
 
530
573
  hasCountdown() { return this.state.doc.rows.some((r) => r.cols.some((c) => c.blocks.some((b) => b.type === 'countdown'))); }
@@ -620,7 +663,10 @@ export class EditorCore {
620
663
  this.commit((doc) => {
621
664
  const f = this.find(doc, id);
622
665
  const target = f.block ? f.block.props : (f.row ? f.row.props : null);
623
- if (target) target[key] = val;
666
+ if (!target) return;
667
+ // Before the write: the size rewrite needs the outgoing value as its base.
668
+ if (f.block) syncRichContent(f.block, key, val);
669
+ target[key] = val;
624
670
  });
625
671
  }
626
672
 
@@ -1190,6 +1236,12 @@ export class EditorCore {
1190
1236
  this.flash(this.t('toast.htmlCopied'));
1191
1237
  };
1192
1238
 
1239
+ /** Code view's source pane onto the clipboard, unsaved edits included: `codeSrc` is what the pane shows, which may be ahead of what Apply has pushed to the canvas. */
1240
+ copyCode = () => {
1241
+ if (navigator.clipboard) navigator.clipboard.writeText(this.state.codeSrc).catch(() => {});
1242
+ this.flash(this.t('toast.htmlCopied'));
1243
+ };
1244
+
1193
1245
  downloadExport = () => {
1194
1246
  const blob = new Blob([this.state.exportCode], { type: 'text/html' });
1195
1247
  const a = document.createElement('a');
@@ -1321,7 +1373,7 @@ export class EditorCore {
1321
1373
  /<a(?:\s|>)/i.test(String(b.props.html || ''))
1322
1374
  ? [{ kind: 'richLinks', label: 'Links', html: b.props.html || '', onChange: (v) => this.setProp(b.id, 'html', v) }]
1323
1375
  : [],
1324
- [B.area('Text', 'html'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 96, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '500', label: 'Medium' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
1376
+ [B.area('Text', 'html'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', ...SIZE_SPAN.text, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '500', label: 'Medium' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
1325
1377
  case 'image': return decorate(base.concat([B.btn('Choose from library', () => this.openLibrary({ id: b.id, key: 'src' })), B.text('Alt text', 'alt', 'Describe the image'), B.text('Link URL', 'href', 'https://'), B.range('Width', 'width', 5, 100, 1, '%'), B.seg('Align', 'align', ALIGN), B.range('Rounded corners', 'radius', 0, 200, 1, 'px')], padF));
1326
1378
  case 'button': return decorate(base.concat([B.text('Label', 'label'), B.text('Link URL', 'href', 'https://'), B.color('Button color', 'bg'), B.color('Text color', 'color'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 48, 1, 'px'), B.range('Rounded corners', 'radius', 0, 60, 1, 'px'), B.range('Outline thickness', 'borderW', 0, 6, 1, 'px')].concat(b.props.borderW ? [B.sel('Outline style', 'borderStyle', BORDER_STYLES), B.color('Outline color', 'borderColor')] : []).concat([B.range('Button height', 'py', 0, 60, 1, 'px'), B.range('Button width', 'px', 0, 120, 2, 'px'), B.seg('Align', 'align', ALIGN), B.tog('Full width', 'full')])));
1327
1379
  case 'divider': return decorate(base.concat([B.range('Thickness', 'thickness', 1, 20, 1, 'px'), B.sel('Line style', 'lineStyle', BORDER_STYLES), B.range('Width', 'width', 5, 100, 5, '%'), B.color('Color', 'color'), B.range('Space above & below', 'py', 0, 160, 2, 'px')]));
@@ -1355,7 +1407,7 @@ export class EditorCore {
1355
1407
  case 'html': return decorate(base.concat([B.area('Raw HTML', 'code')]));
1356
1408
  case 'countdown': return decorate(base.concat([B.text('Ends at (YYYY-MM-DDTHH:MM)', 'target'), B.text('Label', 'label'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.color('Color', 'color')]));
1357
1409
  case 'menu': return decorate(base.concat([B.area('Items — one per line as Label|URL', 'items'), B.seg('Align', 'align', ALIGN), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 32, 1, 'px'), B.range('Space between items', 'gap', 0, 80, 2, 'px'), B.color('Text color', 'color')]));
1358
- case 'heading': return decorate(base.concat([B.area('Text', 'text'), B.sel('Heading level', 'level', [{ value: 'h1', label: 'H1 — largest' }, { value: 'h2', label: 'H2' }, { value: 'h3', label: 'H3' }, { value: 'h4', label: 'H4 — smallest' }]), B.seg('Font style', 'font', [{ value: 'condensed', label: 'Condensed' }, { value: 'body', label: 'Body' }]), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 12, 120, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 2.2, 0.02, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '600', label: 'Semibold' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
1410
+ case 'heading': return decorate(base.concat([B.area('Text', 'text'), B.sel('Heading level', 'level', [{ value: 'h1', label: 'H1 — largest' }, { value: 'h2', label: 'H2' }, { value: 'h3', label: 'H3' }, { value: 'h4', label: 'H4 — smallest' }]), B.seg('Font style', 'font', [{ value: 'condensed', label: 'Condensed' }, { value: 'body', label: 'Body' }]), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', ...SIZE_SPAN.heading, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 2.2, 0.02, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '600', label: 'Semibold' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
1359
1411
  case 'list': return decorate(base.concat([B.area('Items — one per line', 'items'), B.tog('Numbered', 'ordered'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 48, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.range('Space between items', 'gap', 0, 60, 1, 'px'), B.color('Text color', 'color')], padF));
1360
1412
  case 'table': return decorate(base.concat([
1361
1413
  // Custom kind (render/fields.js `renderTableGrid`): a real grid of
@@ -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);