react-email-locale-lab 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,27 +10,41 @@ During local development, the library renders a React Email template and generat
10
10
 
11
11
  The generated translations are intended only for visual inspection of the templates. The library does not replace the localization workflow or the reviewed translations used by the application.
12
12
 
13
- ## Install
13
+ ## Quick start
14
14
 
15
15
  ```bash
16
16
  pnpm add -D react-email-locale-lab
17
17
  ```
18
18
 
19
- The package supports React and React DOM 18 or 19 as peer dependencies.
19
+ The package supports React and React DOM 18 or 19 and does not require Vite. The following quick start uses Vite as an optional development host:
20
20
 
21
- Create a configuration in the consuming project:
21
+ ```bash
22
+ pnpm add -D vite
23
+ ```
24
+
25
+ ```html
26
+ <!-- email-lab/index.html -->
27
+ <div id="root"></div>
28
+ <script type="module" src="/main.tsx"></script>
29
+ ```
22
30
 
23
31
  ```tsx
32
+ // email-lab/main.tsx
33
+ /// <reference types="vite/client" />
34
+
35
+ import { createRoot } from 'react-dom/client';
24
36
  import {
25
37
  browserTranslatorProvider,
26
38
  defineEmailLab,
27
39
  EmailLabApp,
28
40
  } from 'react-email-locale-lab';
41
+ import { viteSourceUpdates } from 'react-email-locale-lab/vite';
29
42
  import 'react-email-locale-lab/styles.css';
43
+ import { WelcomeEmail } from '../src/emails/welcome';
30
44
 
31
45
  const config = defineEmailLab({
32
46
  routeBasePath: '/preview',
33
- watchPaths: ['src/emails'],
47
+ sourceUpdates: viteSourceUpdates(import.meta.hot, { watchPaths: ['src/emails'] }),
34
48
  sourceLocale: { code: 'en', label: 'English' },
35
49
  locales: [{ code: 'de', label: 'Deutsch' }],
36
50
  provider: browserTranslatorProvider(),
@@ -39,28 +53,33 @@ const config = defineEmailLab({
39
53
  },
40
54
  });
41
55
 
42
- export const App = () => <EmailLabApp config={config} />;
56
+ createRoot(document.getElementById('root')!).render(<EmailLabApp config={config} />);
43
57
  ```
44
58
 
45
- Import `react-email-locale-lab/styles.css` once in the consuming application. During local development of this repository, `pnpm pack` can be used to create an installable tarball before publishing to npm.
59
+ Add a script and start the lab:
46
60
 
47
- ## Run
61
+ ```json
62
+ {
63
+ "scripts": {
64
+ "email:lab": "vite email-lab --port 4174"
65
+ }
66
+ }
67
+ ```
48
68
 
49
69
  ```bash
50
- pnpm install
51
- pnpm dev
70
+ pnpm email:lab
52
71
  ```
53
72
 
54
- Open `http://localhost:4173/preview/welcome`, select up to three languages, then edit `src/emails/welcome.tsx`. Vite refreshes the source and the selected locale cards transition through `stale → translating → ready`.
73
+ Open `http://localhost:4174/preview/welcome`, select up to three languages, then edit the template. Vite refreshes the selected previews automatically.
55
74
 
56
75
  ## Configuration interface
57
76
 
58
- `src/email-lab.config.tsx` defines only the source locale, available target locales, templates, and translation provider. There are no manually maintained translations. The user selects zero to three languages in the UI; translation is not initialized during app startup.
77
+ The lab configuration defines only the source locale, available target locales, templates, and translation provider. There are no manually maintained translations. The user selects zero to three languages in the UI; translation is not initialized during app startup.
59
78
 
60
79
  ```tsx
61
80
  export default defineEmailLab({
62
81
  routeBasePath: '/preview',
63
- watchPaths: ['src/emails'],
82
+ sourceUpdates: viteSourceUpdates(import.meta.hot, { watchPaths: ['src/emails'] }),
64
83
  sourceLocale: { code: 'en', label: 'English' },
65
84
  locales: [
66
85
  { code: 'de', label: 'Deutsch' },
@@ -85,28 +104,36 @@ templates: {
85
104
 
86
105
  Set `props` on the template entry to override `PreviewProps`. A custom `render` function remains available for templates that need bespoke setup.
87
106
 
88
- ## Template locations and source watching
107
+ ### Template requirements
89
108
 
90
- Templates can be imported from anywhere in the consuming project. They do not need to live in `src/emails` or follow a specific folder structure.
109
+ The lab renders templates synchronously to static HTML. Prefer presentation-only email components whose output is determined by props. Pass preview data through `PreviewProps` or `props`; fetch production data before rendering the email.
91
110
 
92
- Use `watchPaths` to select the directories that should trigger preview regeneration during Vite development. Each entry is matched as a path fragment:
111
+ Data fetching in `useEffect`, async/Server Components, application routes and components that require missing providers may not render as expected. Use `render` to add synchronous providers or wrappers:
93
112
 
94
113
  ```tsx
95
- export default defineEmailLab({
96
- watchPaths: [
97
- 'src/domains/billing/notifications',
98
- 'src/shared/message-templates',
99
- 'packages/transactional-mail',
100
- ],
101
- templates: {
102
- invoice: { name: 'Invoice', component: InvoiceEmail },
103
- welcome: { name: 'Welcome', component: WelcomeEmail },
114
+ templates: {
115
+ welcome: {
116
+ name: 'Welcome',
117
+ render: () => <WelcomeEmail />,
104
118
  },
105
- // sourceLocale, locales and provider...
106
- });
119
+ }
120
+ ```
121
+
122
+ ## Template locations and source updates
123
+
124
+ Templates can be imported from anywhere in the consuming project. They do not need to live in `src/emails` or follow a specific folder structure.
125
+
126
+ The core library is bundler-agnostic. Without `sourceUpdates`, previews still work and can be regenerated manually. Vite users can opt into HMR updates and restrict them to path fragments:
127
+
128
+ ```tsx
129
+ import { viteSourceUpdates } from 'react-email-locale-lab/vite';
130
+
131
+ sourceUpdates: viteSourceUpdates(import.meta.hot, {
132
+ watchPaths: ['src/message-templates', 'packages/notifications'],
133
+ }),
107
134
  ```
108
135
 
109
- When `watchPaths` is omitted, the lab reloads for changes to JavaScript and TypeScript modules anywhere in the Vite application. In a larger codebase, configure it to avoid regenerating previews for unrelated source changes.
136
+ Omit `watchPaths` to react to any JavaScript or TypeScript module update. Other tools can implement the small `SourceUpdates` subscription interface and pass it through `sourceUpdates`.
110
137
 
111
138
  ## Template routes
112
139
 
@@ -132,9 +159,11 @@ nl no pl pt ro ru sk sl sv ta te th tr uk vi zh zh-Hant
132
159
 
133
160
  The list is provider-specific and may change. The provider checks every source/target pair with `Translator.availability()` at runtime. Chrome's API currently runs on desktop, downloads language packs on demand and is unavailable on mobile. See the [official Translator API language list](https://developer.chrome.com/docs/ai/translator-api#supported-languages).
134
161
 
135
- The provider receives extracted text nodes and translatable attributes (`alt`, `title`, `aria-label`). When source copy changes, every target preview is marked stale and new or changed messages are automatically translated again. Unchanged messages are reused from the in-memory cache.
162
+ The provider receives extracted text nodes and translatable attributes (`alt`, `title`, `aria-label`). When source copy changes, every target preview is marked stale and new or changed messages are automatically translated again. Unchanged messages are reused from the session cache.
163
+
164
+ The included provider uses the browser's built-in Translator API. Language packs are lazy-loaded per selected pair, and translated messages are cached by source text and locale in session storage so unchanged copy is reused across Vite reloads. The source template does not leave the browser. In a browser without this API, the preview reports an actionable error instead of downloading a large JavaScript model or freezing startup.
136
165
 
137
- The included provider uses the browser's built-in Translator API. Language packs are lazy-loaded per selected pair, and translated messages are cached by source text and locale. The source template does not leave the browser. In a browser without this API, the preview reports an actionable error instead of downloading a large JavaScript model or freezing startup.
166
+ Translated previews set both the BCP 47 `lang` attribute and the corresponding `dir` attribute on the rendered document. Right-to-left locales such as Arabic and Hebrew therefore render with `dir="rtl"`; other target locales explicitly use `dir="ltr"`.
138
167
 
139
168
  ## Current scope
140
169
 
@@ -0,0 +1,14 @@
1
+ import type { SourceUpdates } from '../core/types';
2
+ type ViteUpdatePayload = {
3
+ updates: Array<{
4
+ path: string;
5
+ }>;
6
+ };
7
+ type ViteHotContext = {
8
+ on: (event: 'vite:afterUpdate', listener: (payload: ViteUpdatePayload) => void) => void;
9
+ off: (event: 'vite:afterUpdate', listener: (payload: ViteUpdatePayload) => void) => void;
10
+ };
11
+ export declare const viteSourceUpdates: (hot: ViteHotContext | undefined, options?: {
12
+ watchPaths?: string[];
13
+ }) => SourceUpdates;
14
+ export {};
@@ -1,3 +1,4 @@
1
+ export declare const directionForLocale: (locale: string) => "ltr" | "rtl";
1
2
  export type MessageSlot = {
2
3
  value: string;
3
4
  apply: (translated: string) => void;
@@ -27,12 +27,15 @@ export type TranslationProvider = {
27
27
  name: string;
28
28
  translate: (request: TranslationRequest) => Promise<string[]>;
29
29
  };
30
+ export type SourceUpdates = {
31
+ subscribe: (onUpdate: () => void) => () => void;
32
+ };
30
33
  export type EmailLabConfig = {
31
34
  sourceLocale: Locale;
32
35
  locales: Locale[];
33
36
  templates: Record<string, EmailTemplate>;
34
37
  provider: TranslationProvider;
35
38
  routeBasePath?: string;
36
- watchPaths?: string[];
39
+ sourceUpdates?: SourceUpdates;
37
40
  };
38
41
  export declare const defineEmailLab: (config: EmailLabConfig) => EmailLabConfig;
package/dist/index.d.ts CHANGED
@@ -2,4 +2,4 @@ import './styles.css';
2
2
  export { EmailLabApp } from './App';
3
3
  export { browserTranslatorProvider } from './core/browser-translator-provider';
4
4
  export { defineEmailLab } from './core/types';
5
- export type { EmailLabConfig, EmailTemplate, Locale, TranslationProvider, TranslationRequest, } from './core/types';
5
+ export type { EmailLabConfig, EmailTemplate, Locale, SourceUpdates, TranslationProvider, TranslationRequest, } from './core/types';
package/dist/index.js CHANGED
@@ -1,159 +1,183 @@
1
- import { jsxs as d, jsx as c } from "react/jsx-runtime";
2
- import { createElement as I, useMemo as A, useState as L, useEffect as T } from "react";
3
- import { renderToStaticMarkup as M } from "react-dom/server";
4
- const k = ["alt", "title", "aria-label"], $ = (e) => new RegExp("\\p{L}", "u").test(e), j = (e) => {
5
- const t = [], a = e.createTreeWalker(e.body, NodeFilter.SHOW_TEXT);
6
- let n = a.nextNode();
7
- for (; n; ) {
8
- const s = n, r = s.parentElement, l = s.data.trim();
9
- if (r && !["STYLE", "SCRIPT"].includes(r.tagName) && $(l)) {
10
- const o = s.data.match(/^\s*/)?.[0] ?? "", u = s.data.match(/\s*$/)?.[0] ?? "";
11
- t.push({ value: l, apply: (w) => {
12
- s.data = `${o}${w}${u}`;
1
+ import { jsxs as d, jsx as i } from "react/jsx-runtime";
2
+ import { createElement as B, useMemo as R, useState as L, useEffect as S } from "react";
3
+ import { renderToStaticMarkup as F } from "react-dom/server";
4
+ const _ = ["alt", "title", "aria-label"], O = /* @__PURE__ */ new Set([
5
+ "ar",
6
+ "arc",
7
+ "ckb",
8
+ "dv",
9
+ "fa",
10
+ "he",
11
+ "khw",
12
+ "ks",
13
+ "ku",
14
+ "nqo",
15
+ "ps",
16
+ "sd",
17
+ "syr",
18
+ "ug",
19
+ "ur",
20
+ "yi"
21
+ ]), H = /* @__PURE__ */ new Set(["adlm", "arab", "hebr", "mand", "nkoo", "rohg", "samr", "syrc", "thaa"]), j = (e) => {
22
+ const t = e.trim().split(/[-_]/), r = t[0].toLowerCase(), s = t.findIndex((o, c) => c > 0 && /^[a-z0-9]$/i.test(o)), a = t.slice(1, s === -1 ? void 0 : s).find((o) => /^[a-z]{4}$/i.test(o));
23
+ return a ? H.has(a.toLowerCase()) ? "rtl" : "ltr" : O.has(r) ? "rtl" : "ltr";
24
+ }, C = (e) => new RegExp("\\p{L}", "u").test(e), z = (e) => {
25
+ const t = [], r = e.createTreeWalker(e.body, NodeFilter.SHOW_TEXT);
26
+ let s = r.nextNode();
27
+ for (; s; ) {
28
+ const n = s, a = n.parentElement, o = n.data.trim();
29
+ if (a && !["STYLE", "SCRIPT"].includes(a.tagName) && C(o)) {
30
+ const c = n.data.match(/^\s*/)?.[0] ?? "", u = n.data.match(/\s*$/)?.[0] ?? "";
31
+ t.push({ value: o, apply: (w) => {
32
+ n.data = `${c}${w}${u}`;
13
33
  } });
14
34
  }
15
- n = a.nextNode();
35
+ s = r.nextNode();
16
36
  }
17
- for (const s of e.body.querySelectorAll("*"))
18
- for (const r of k) {
19
- const l = s.getAttribute(r)?.trim();
20
- l && $(l) && t.push({ value: l, apply: (o) => s.setAttribute(r, o) });
37
+ for (const n of e.body.querySelectorAll("*"))
38
+ for (const a of _) {
39
+ const o = n.getAttribute(a)?.trim();
40
+ o && C(o) && t.push({ value: o, apply: (c) => n.setAttribute(a, c) });
21
41
  }
22
42
  return t;
23
- }, H = async (e, t, a, n) => {
24
- if (a === t) return e;
25
- const s = new DOMParser().parseFromString(e, "text/html");
26
- s.documentElement.lang = a;
27
- const r = j(s), l = await n(r.map((o) => o.value));
28
- return r.forEach((o, u) => o.apply(l[u] ?? o.value)), `<!doctype html>${s.documentElement.outerHTML}`;
43
+ }, q = async (e, t, r, s) => {
44
+ if (r === t) return e;
45
+ const n = new DOMParser().parseFromString(e, "text/html");
46
+ n.documentElement.lang = r, n.documentElement.dir = j(r);
47
+ const a = z(n), o = await s(a.map((c) => c.value));
48
+ return a.forEach((c, u) => c.apply(o[u] ?? c.value)), `<!doctype html>${n.documentElement.outerHTML}`;
29
49
  }, U = async (e) => {
30
- const t = new TextEncoder().encode(e), a = await crypto.subtle.digest("SHA-256", t);
31
- return Array.from(new Uint8Array(a).slice(0, 6), (n) => n.toString(16).padStart(2, "0")).join("");
32
- }, O = (e) => {
50
+ const t = new TextEncoder().encode(e), r = await crypto.subtle.digest("SHA-256", t);
51
+ return Array.from(new Uint8Array(r).slice(0, 6), (s) => s.toString(16).padStart(2, "0")).join("");
52
+ }, D = (e) => {
33
53
  const t = new DOMParser().parseFromString(e, "text/html");
34
54
  return Array.from(t.body.querySelectorAll("[style]")).find(
35
- (s) => s.style.display === "none" && s.textContent?.trim()
55
+ (n) => n.style.display === "none" && n.textContent?.trim()
36
56
  )?.textContent?.replace(/[\u00A0\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, "").trim() || void 0;
37
- }, _ = ({ locale: e, preview: t, sourceRevision: a, onRefresh: n }) => /* @__PURE__ */ d("article", { className: "preview-card", children: [
57
+ }, G = ({ locale: e, preview: t, sourceRevision: r, onRefresh: s }) => /* @__PURE__ */ d("article", { className: "preview-card", children: [
38
58
  /* @__PURE__ */ d("header", { className: "preview-header", children: [
39
59
  /* @__PURE__ */ d("div", { children: [
40
- /* @__PURE__ */ c("strong", { children: e.label }),
41
- /* @__PURE__ */ c("span", { children: e.code })
60
+ /* @__PURE__ */ i("strong", { children: e.label }),
61
+ /* @__PURE__ */ i("span", { children: e.code })
42
62
  ] }),
43
63
  /* @__PURE__ */ d("div", { className: `status status-${t.status}`, children: [
44
- /* @__PURE__ */ c("i", {}),
64
+ /* @__PURE__ */ i("i", {}),
45
65
  t.status
46
66
  ] })
47
67
  ] }),
48
68
  /* @__PURE__ */ d("div", { className: "revision", children: [
49
69
  "source ",
50
- a || "…",
70
+ r || "…",
51
71
  " · preview ",
52
72
  t.revision ?? "pending"
53
73
  ] }),
54
74
  /* @__PURE__ */ d("div", { className: "preheader", children: [
55
- /* @__PURE__ */ c("span", { children: "Preheader" }),
56
- /* @__PURE__ */ c("strong", { children: O(t.html) ?? "Not set" })
75
+ /* @__PURE__ */ i("span", { children: "Preheader" }),
76
+ /* @__PURE__ */ i("strong", { children: D(t.html) ?? "Not set" })
57
77
  ] }),
58
- /* @__PURE__ */ c("iframe", { title: `${e.label} preview`, srcDoc: t.html, sandbox: "allow-popups allow-popups-to-escape-sandbox" }),
59
- t.error && /* @__PURE__ */ c("div", { className: "preview-error", children: t.error }),
60
- t.status !== "source" && /* @__PURE__ */ c("button", { className: "refresh", onClick: n, children: "Regenerate translation" })
61
- ] }), z = (e) => {
78
+ /* @__PURE__ */ i("iframe", { title: `${e.label} preview`, srcDoc: t.html, sandbox: "allow-popups allow-popups-to-escape-sandbox" }),
79
+ t.error && /* @__PURE__ */ i("div", { className: "preview-error", children: t.error }),
80
+ t.status !== "source" && /* @__PURE__ */ i("button", { className: "refresh", onClick: s, children: "Regenerate translation" })
81
+ ] }), W = (e) => {
62
82
  if ("render" in e && e.render) return e.render();
63
83
  const t = e.props ?? e.component.PreviewProps ?? {};
64
- return I(e.component, t);
65
- }, x = "/preview", F = (e = x) => `/${e}`.replace(/\/+/g, "/").replace(/\/$/, "") || x, N = (e, t, a) => {
66
- const n = F(a), r = (e.pathname.startsWith(`${n}/`) ? decodeURIComponent(e.pathname.slice(n.length + 1)) : void 0) || e.searchParams.get("template") || void 0;
67
- return r && t.includes(r) ? r : t[0];
68
- }, C = (e, t = 3) => e.searchParams.get("langs")?.split(",").filter(Boolean).slice(0, t) ?? [], B = (e, t, a) => {
69
- const n = new URL(e);
70
- return n.pathname = `${F(a)}/${encodeURIComponent(t)}`, n.searchParams.delete("template"), n;
71
- }, q = (e, t) => {
72
- const a = new URL(e);
73
- return t.length ? a.searchParams.set("langs", t.join(",")) : a.searchParams.delete("langs"), a;
74
- }, R = 3, D = (e) => {
75
- const t = A(() => Object.keys(e.templates), [e.templates]), [a, n] = L(() => N(new URL(window.location.href), t, e.routeBasePath)), [s, r] = L(() => C(new URL(window.location.href), R)), [l, o] = L({}), [u, w] = L(""), [g, y] = L(0), b = e.templates[a] ?? e.templates[t[0]], h = A(() => `<!doctype html>${M(z(b))}`, [b]), P = e.locales.filter((i) => s.includes(i.code)), S = [e.sourceLocale, ...P];
76
- return T(() => {
77
- const i = N(new URL(window.location.href), t, e.routeBasePath);
78
- window.history.replaceState({}, "", B(new URL(window.location.href), i, e.routeBasePath));
79
- const v = () => {
80
- n(N(new URL(window.location.href), t, e.routeBasePath)), r(C(new URL(window.location.href), R));
84
+ return B(e.component, t);
85
+ }, $ = "/preview", k = (e = $) => `/${e}`.replace(/\/+/g, "/").replace(/\/$/, "") || $, E = (e, t, r) => {
86
+ const s = k(r), a = (e.pathname.startsWith(`${s}/`) ? decodeURIComponent(e.pathname.slice(s.length + 1)) : void 0) || e.searchParams.get("template") || void 0;
87
+ return a && t.includes(a) ? a : t[0];
88
+ }, x = (e, t = 3) => e.searchParams.get("langs")?.split(",").filter(Boolean).slice(0, t) ?? [], I = (e, t, r) => {
89
+ const s = new URL(e);
90
+ return s.pathname = `${k(r)}/${encodeURIComponent(t)}`, s.searchParams.delete("template"), s;
91
+ }, V = (e, t) => {
92
+ const r = new URL(e);
93
+ return t.length ? r.searchParams.set("langs", t.join(",")) : r.searchParams.delete("langs"), r;
94
+ }, N = 3, J = (e) => {
95
+ const t = R(() => Object.keys(e.templates), [e.templates]), [r, s] = L(() => E(new URL(window.location.href), t, e.routeBasePath)), [n, a] = L(() => x(new URL(window.location.href), N)), [o, c] = L({}), [u, w] = L(""), [v, b] = L(0), f = e.templates[r] ?? e.templates[t[0]], p = R(
96
+ () => `<!doctype html>${F(W(f))}`,
97
+ [f, v]
98
+ ), g = e.locales.filter((l) => n.includes(l.code)), A = [e.sourceLocale, ...g];
99
+ return S(() => {
100
+ const l = E(new URL(window.location.href), t, e.routeBasePath);
101
+ window.history.replaceState({}, "", I(new URL(window.location.href), l, e.routeBasePath));
102
+ const y = () => {
103
+ s(E(new URL(window.location.href), t, e.routeBasePath)), a(x(new URL(window.location.href), N));
81
104
  };
82
- return window.addEventListener("popstate", v), () => window.removeEventListener("popstate", v);
83
- }, [e.routeBasePath, t]), T(() => () => {
84
- }, [e.watchPaths]), T(() => {
85
- let i = !1;
86
- return U(h).then((v) => {
87
- i || w(v);
105
+ return window.addEventListener("popstate", y), () => window.removeEventListener("popstate", y);
106
+ }, [e.routeBasePath, t]), S(() => e.sourceUpdates?.subscribe(() => {
107
+ b((l) => l + 1);
108
+ }), [e.sourceUpdates]), S(() => {
109
+ let l = !1;
110
+ return U(p).then((y) => {
111
+ l || w(y);
88
112
  }), () => {
89
- i = !0;
113
+ l = !0;
90
114
  };
91
- }, [h]), T(() => {
92
- let i = !1;
93
- return o((m) => Object.fromEntries(S.map((p) => [
94
- p.code,
95
- p.code === e.sourceLocale.code ? { html: h, status: "source" } : { html: m[p.code]?.html ?? h, status: "stale", revision: m[p.code]?.revision }
115
+ }, [p]), S(() => {
116
+ let l = !1;
117
+ return c((h) => Object.fromEntries(A.map((m) => [
118
+ m.code,
119
+ m.code === e.sourceLocale.code ? { html: p, status: "source" } : { html: h[m.code]?.html ?? p, status: "stale", revision: h[m.code]?.revision }
96
120
  ]))), (async () => {
97
- for (const m of P) {
98
- if (i) return;
99
- o((p) => ({
100
- ...p,
101
- [m.code]: { ...p[m.code], status: "translating", error: void 0 }
121
+ for (const h of g) {
122
+ if (l) return;
123
+ c((m) => ({
124
+ ...m,
125
+ [h.code]: { ...m[h.code], status: "translating", error: void 0 }
102
126
  }));
103
127
  try {
104
- const p = await H(h, e.sourceLocale.code, m.code, (E) => e.provider.translate({
105
- texts: E,
128
+ const m = await q(p, e.sourceLocale.code, h.code, (P) => e.provider.translate({
129
+ texts: P,
106
130
  sourceLocale: e.sourceLocale.translationCode ?? e.sourceLocale.code,
107
- targetLocale: m.translationCode ?? m.code
108
- })), f = await U(h);
109
- i || o((E) => ({ ...E, [m.code]: { html: p, status: "ready", revision: f } }));
110
- } catch (p) {
111
- i || o((f) => ({
112
- ...f,
113
- [m.code]: {
114
- ...f[m.code],
131
+ targetLocale: h.translationCode ?? h.code
132
+ })), T = await U(p);
133
+ l || c((P) => ({ ...P, [h.code]: { html: m, status: "ready", revision: T } }));
134
+ } catch (m) {
135
+ l || c((T) => ({
136
+ ...T,
137
+ [h.code]: {
138
+ ...T[h.code],
115
139
  status: "error",
116
- error: p instanceof Error ? p.message : String(p)
140
+ error: m instanceof Error ? m.message : String(m)
117
141
  }
118
142
  }));
119
143
  }
120
144
  }
121
145
  })(), () => {
122
- i = !0;
146
+ l = !0;
123
147
  };
124
- }, [h, g, s.join(",")]), {
125
- activeLocaleCodes: s,
126
- activeLocales: P,
127
- previewLocales: S,
128
- previews: l,
129
- refreshPreviews: () => y((i) => i + 1),
130
- selectTemplate: (i) => {
131
- n(i), window.history.pushState({}, "", B(new URL(window.location.href), i, e.routeBasePath));
148
+ }, [p, v, n.join(",")]), {
149
+ activeLocaleCodes: n,
150
+ activeLocales: g,
151
+ previewLocales: A,
152
+ previews: o,
153
+ refreshPreviews: () => b((l) => l + 1),
154
+ selectTemplate: (l) => {
155
+ s(l), window.history.pushState({}, "", I(new URL(window.location.href), l, e.routeBasePath));
132
156
  },
133
- selectedTemplateId: a,
134
- sourceHtml: h,
157
+ selectedTemplateId: r,
158
+ sourceHtml: p,
135
159
  sourceRevision: u,
136
160
  templateIds: t,
137
- toggleLocale: (i) => {
138
- const v = s.includes(i) ? s.filter((m) => m !== i) : [...s, i].slice(0, R);
139
- r(v), window.history.replaceState({}, "", q(new URL(window.location.href), v));
161
+ toggleLocale: (l) => {
162
+ const y = n.includes(l) ? n.filter((h) => h !== l) : [...n, l].slice(0, N);
163
+ a(y), window.history.replaceState({}, "", V(new URL(window.location.href), y));
140
164
  }
141
165
  };
142
- }, J = ({ config: e }) => {
143
- const t = D(e);
166
+ }, re = ({ config: e }) => {
167
+ const t = J(e);
144
168
  return /* @__PURE__ */ d("main", { className: "email-lab", children: [
145
169
  /* @__PURE__ */ d("header", { className: "topbar", children: [
146
170
  /* @__PURE__ */ d("div", { className: "topbar-copy", children: [
147
171
  /* @__PURE__ */ d("div", { className: "brand", children: [
148
- /* @__PURE__ */ c("span", { className: "brand-mark" }),
149
- /* @__PURE__ */ c("p", { className: "eyebrow", children: "React Email Locale Lab" })
172
+ /* @__PURE__ */ i("span", { className: "brand-mark" }),
173
+ /* @__PURE__ */ i("p", { className: "eyebrow", children: "React Email Locale Lab" })
150
174
  ] }),
151
- /* @__PURE__ */ c("h1", { children: "See every language while you build." })
175
+ /* @__PURE__ */ i("h1", { children: "See every language while you build." })
152
176
  ] }),
153
177
  /* @__PURE__ */ d("div", { className: "controls", children: [
154
178
  /* @__PURE__ */ d("label", { children: [
155
179
  "Template",
156
- /* @__PURE__ */ c("select", { value: t.selectedTemplateId, onChange: (a) => t.selectTemplate(a.target.value), children: t.templateIds.map((a) => /* @__PURE__ */ c("option", { value: a, children: e.templates[a].name }, a)) })
180
+ /* @__PURE__ */ i("select", { value: t.selectedTemplateId, onChange: (r) => t.selectTemplate(r.target.value), children: t.templateIds.map((r) => /* @__PURE__ */ i("option", { value: r, children: e.templates[r].name }, r)) })
157
181
  ] }),
158
182
  /* @__PURE__ */ d("span", { className: "provider", children: [
159
183
  "Provider: ",
@@ -163,88 +187,109 @@ const k = ["alt", "title", "aria-label"], $ = (e) => new RegExp("\\p{L}", "u").t
163
187
  ] }),
164
188
  /* @__PURE__ */ d("section", { className: "locale-picker", children: [
165
189
  /* @__PURE__ */ d("div", { children: [
166
- /* @__PURE__ */ c("strong", { children: "Preview languages" }),
167
- /* @__PURE__ */ c("span", { children: "Select up to three. Language packs load only when selected." })
190
+ /* @__PURE__ */ i("strong", { children: "Preview languages" }),
191
+ /* @__PURE__ */ i("span", { children: "Select up to three. Language packs load only when selected." })
168
192
  ] }),
169
- /* @__PURE__ */ c("div", { className: "locale-options", children: e.locales.map((a) => /* @__PURE__ */ d(
193
+ /* @__PURE__ */ i("div", { className: "locale-options", children: e.locales.map((r) => /* @__PURE__ */ d(
170
194
  "button",
171
195
  {
172
- className: t.activeLocaleCodes.includes(a.code) ? "locale-active" : "",
173
- disabled: !t.activeLocaleCodes.includes(a.code) && t.activeLocaleCodes.length >= 3,
174
- onClick: () => t.toggleLocale(a.code),
196
+ className: t.activeLocaleCodes.includes(r.code) ? "locale-active" : "",
197
+ disabled: !t.activeLocaleCodes.includes(r.code) && t.activeLocaleCodes.length >= 3,
198
+ onClick: () => t.toggleLocale(r.code),
175
199
  children: [
176
- /* @__PURE__ */ c("i", {}),
177
- a.label
200
+ /* @__PURE__ */ i("i", {}),
201
+ r.label
178
202
  ]
179
203
  },
180
- a.code
204
+ r.code
181
205
  )) })
182
206
  ] }),
183
207
  /* @__PURE__ */ d("section", { className: "notice", children: [
184
- /* @__PURE__ */ c("span", { className: "live-dot" }),
208
+ /* @__PURE__ */ i("span", { className: "live-dot" }),
185
209
  "Watching source changes through Vite HMR."
186
210
  ] }),
187
211
  t.activeLocales.length === 0 && /* @__PURE__ */ d("section", { className: "empty-state", children: [
188
- /* @__PURE__ */ c("strong", { children: "Select a language to generate a preview." }),
189
- /* @__PURE__ */ c("span", { children: "The source template renders immediately; translation starts only on demand." })
212
+ /* @__PURE__ */ i("strong", { children: "Select a language to generate a preview." }),
213
+ /* @__PURE__ */ i("span", { children: "The source template renders immediately; translation starts only on demand." })
190
214
  ] }),
191
- /* @__PURE__ */ c("section", { className: "grid", children: t.previewLocales.map((a) => /* @__PURE__ */ c(
192
- _,
215
+ /* @__PURE__ */ i("section", { className: "grid", children: t.previewLocales.map((r) => /* @__PURE__ */ i(
216
+ G,
193
217
  {
194
- locale: a,
195
- preview: t.previews[a.code] ?? { html: t.sourceHtml, status: "stale" },
218
+ locale: r,
219
+ preview: t.previews[r.code] ?? { html: t.sourceHtml, status: "stale" },
196
220
  sourceRevision: t.sourceRevision,
197
221
  onRefresh: t.refreshPreviews
198
222
  },
199
- a.code
223
+ r.code
200
224
  )) })
201
225
  ] });
202
- }, K = () => {
203
- const e = /* @__PURE__ */ new Map(), t = /* @__PURE__ */ new Map(), a = /* @__PURE__ */ new Map(), n = async (r, l) => {
204
- const o = a.get(r) ?? Promise.resolve();
226
+ }, M = "react-email-locale-lab:translations:v1", Y = () => {
227
+ try {
228
+ const e = window.sessionStorage?.getItem(M);
229
+ if (!e) return /* @__PURE__ */ new Map();
230
+ const t = JSON.parse(e);
231
+ return Array.isArray(t) ? new Map(t.filter(
232
+ (r) => Array.isArray(r) && r.length === 2 && r.every((s) => typeof s == "string")
233
+ )) : /* @__PURE__ */ new Map();
234
+ } catch {
235
+ return /* @__PURE__ */ new Map();
236
+ }
237
+ }, K = (e) => {
238
+ try {
239
+ window.sessionStorage?.setItem(M, JSON.stringify([...e]));
240
+ } catch {
241
+ }
242
+ }, ae = () => {
243
+ const e = /* @__PURE__ */ new Map(), t = Y(), r = /* @__PURE__ */ new Map(), s = async (a, o) => {
244
+ const c = r.get(a) ?? Promise.resolve();
205
245
  let u = () => {
206
246
  };
207
- const w = new Promise((y) => {
208
- u = y;
209
- }), g = o.catch(() => {
247
+ const w = new Promise((b) => {
248
+ u = b;
249
+ }), v = c.catch(() => {
210
250
  }).then(() => w);
211
- a.set(r, g), await o.catch(() => {
251
+ r.set(a, v), await c.catch(() => {
212
252
  });
213
253
  try {
214
- return await l();
254
+ return await o();
215
255
  } finally {
216
- u(), a.get(r) === g && a.delete(r);
256
+ u(), r.get(a) === v && r.delete(a);
217
257
  }
218
- }, s = async (r, l) => {
258
+ }, n = async (a, o) => {
219
259
  if (!window.Translator)
220
260
  throw new Error("This browser does not support the built-in Translator API. Use a current Chrome build or configure a remote provider.");
221
- const o = `${r}:${l}`;
222
- let u = e.get(o);
261
+ const c = `${a}:${o}`;
262
+ let u = e.get(c);
223
263
  if (!u) {
224
- if (await window.Translator.availability({ sourceLanguage: r, targetLanguage: l }) === "unavailable") throw new Error(`The browser cannot translate ${r} → ${l}.`);
225
- u = window.Translator.create({ sourceLanguage: r, targetLanguage: l }), e.set(o, u);
264
+ if (await window.Translator.availability({ sourceLanguage: a, targetLanguage: o }) === "unavailable") throw new Error(`The browser cannot translate ${a} → ${o}.`);
265
+ u = window.Translator.create({ sourceLanguage: a, targetLanguage: o }), e.set(c, u);
226
266
  }
227
267
  return u;
228
268
  };
229
269
  return {
230
270
  name: "Browser Translator · on-device",
231
- async translate({ texts: r, sourceLocale: l, targetLocale: o }) {
232
- if (r.length === 0) return [];
233
- const u = await s(l, o), w = `${l}:${o}`;
234
- return n(w, async () => {
235
- const g = [];
236
- for (const y of r) {
237
- const b = `${w}:${y}`;
238
- let h = t.get(b);
239
- h || (h = await u.translate(y), t.set(b, h)), g.push(h);
271
+ async translate({ texts: a, sourceLocale: o, targetLocale: c }) {
272
+ if (a.length === 0) return [];
273
+ const u = await n(o, c), w = `${o}:${c}`;
274
+ return s(w, async () => {
275
+ const v = [];
276
+ let b = !1;
277
+ try {
278
+ for (const f of a) {
279
+ const p = `${w}:${f}`;
280
+ let g = t.get(p);
281
+ g || (g = await u.translate(f), t.set(p, g), b = !0), v.push(g);
282
+ }
283
+ return v;
284
+ } finally {
285
+ b && K(t);
240
286
  }
241
- return g;
242
287
  });
243
288
  }
244
289
  };
245
- }, Q = (e) => e;
290
+ }, se = (e) => e;
246
291
  export {
247
- J as EmailLabApp,
248
- K as browserTranslatorProvider,
249
- Q as defineEmailLab
292
+ re as EmailLabApp,
293
+ ae as browserTranslatorProvider,
294
+ se as defineEmailLab
250
295
  };
package/dist/vite.js ADDED
@@ -0,0 +1,18 @@
1
+ const c = /\.[cm]?[jt]sx?(?:\?|$)/, n = (e) => e.replaceAll("\\", "/").replace(/^\.\//, ""), p = (e, r) => {
2
+ const s = e.map(n), t = r?.map(n).filter(Boolean);
3
+ return t?.length ? s.some(
4
+ (a) => t.some((o) => a.includes(o))
5
+ ) : s.some((a) => c.test(a));
6
+ }, l = (e, r = {}) => ({
7
+ subscribe(s) {
8
+ if (!e) return () => {
9
+ };
10
+ const t = (a) => {
11
+ p(a.updates.map((o) => o.path), r.watchPaths) && s();
12
+ };
13
+ return e.on("vite:afterUpdate", t), () => e.off("vite:afterUpdate", t);
14
+ }
15
+ });
16
+ export {
17
+ l as viteSourceUpdates
18
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-email-locale-lab",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Real-time multi-language previews for React Email templates.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,6 +15,10 @@
15
15
  "types": "./dist/index.d.ts",
16
16
  "import": "./dist/index.js"
17
17
  },
18
+ "./vite": {
19
+ "types": "./dist/adapters/vite.d.ts",
20
+ "import": "./dist/vite.js"
21
+ },
18
22
  "./styles.css": "./dist/styles.css"
19
23
  },
20
24
  "sideEffects": ["./dist/styles.css"],