@remit/ui 0.0.101 → 0.0.102

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.
@@ -38,6 +38,12 @@ export interface RichTextEditorProps {
38
38
  ariaLabel?: string;
39
39
  /** Pinned to the right of the toolbar strip. The mode toggle rides here. */
40
40
  trailing?: React.ReactNode;
41
+ /**
42
+ * BCP 47 tag of the language the message is being written in. Firefox picks
43
+ * a dictionary from it among the ones the user installed; Chrome and Safari
44
+ * ignore it. Every screen reader picks a voice from it.
45
+ */
46
+ lang?: string;
41
47
  }
42
48
 
43
49
  /**
@@ -164,6 +170,7 @@ export const RichTextEditor = ({
164
170
  placeholder = "Write your message…",
165
171
  ariaLabel = "Message body",
166
172
  trailing,
173
+ lang,
167
174
  }: RichTextEditorProps) => (
168
175
  <LexicalComposer
169
176
  initialConfig={{
@@ -185,6 +192,7 @@ export const RichTextEditor = ({
185
192
  <RichTextPlugin
186
193
  contentEditable={
187
194
  <ContentEditable
195
+ lang={lang}
188
196
  aria-label={ariaLabel}
189
197
  aria-placeholder={placeholder}
190
198
  data-testid="compose-body"
@@ -68,9 +68,9 @@ const INITIAL_STATE: ToolbarState = {
68
68
 
69
69
  export interface RichTextToolbarProps {
70
70
  /**
71
- * Pinned to the right of the strip, outside the part that scrolls. The mode
72
- * toggle rides here, and it is the last element in the toolbar's DOM so one
73
- * Shift+Tab out of the body reaches it.
71
+ * Pinned to the right of the strip, outside the part that scrolls. The
72
+ * language chip and the mode toggle ride here, in that order, so one
73
+ * Shift+Tab out of the body reaches the toggle and two reach the chip.
74
74
  */
75
75
  trailing?: React.ReactNode;
76
76
  }
@@ -212,7 +212,11 @@ export const RichTextToolbar = ({ trailing }: RichTextToolbarProps) => {
212
212
  <Redo2 className={`size-4 ${state.canRedo ? "" : "opacity-40"}`} />
213
213
  </ToolbarButton>
214
214
  </div>
215
- {trailing && <div className="ml-auto shrink-0">{trailing}</div>}
215
+ {trailing && (
216
+ <div className="ml-auto flex shrink-0 items-center gap-1">
217
+ {trailing}
218
+ </div>
219
+ )}
216
220
  </div>
217
221
  {linkDraft !== null && (
218
222
  <div className="flex items-center gap-2 border-t border-line px-3 py-1.5">
@@ -0,0 +1,86 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { detectComposeLanguage } from "../lib/detect-compose-language.js";
3
+
4
+ /**
5
+ * Where the composer's current language came from. A value rather than an
6
+ * `isManual` flag: the chip reads it, and the spellchecker slice (#692) will
7
+ * want to know whether a language was chosen or guessed before it spends a
8
+ * worker on it.
9
+ */
10
+ export type ComposeLanguageSource = "account" | "detected" | "manual";
11
+
12
+ export interface ComposeLanguageState {
13
+ language: string;
14
+ source: ComposeLanguageSource;
15
+ }
16
+
17
+ export interface ComposeLanguageControl extends ComposeLanguageState {
18
+ /** The user picked this language. Detection stops for the rest of the message. */
19
+ choose: (tag: string) => void;
20
+ }
21
+
22
+ export interface UseComposeLanguageInput {
23
+ /** The account's configured tags, most-used first. The first is the default. */
24
+ languages: readonly string[];
25
+ /** The body as plain text. The quoted reply block is not part of it. */
26
+ text: string;
27
+ /**
28
+ * The tag a reopened draft was written under. Treated as a choice already
29
+ * made: it is the only record of a manual pick a draft carries, and detection
30
+ * would otherwise overwrite it the moment the draft came back.
31
+ */
32
+ initialLanguage?: string;
33
+ debounceMs?: number;
34
+ }
35
+
36
+ /**
37
+ * The composer's language while a message is being written: the account default
38
+ * until detection has enough text to say otherwise, and whatever the user
39
+ * picked from the moment they pick one.
40
+ */
41
+ export const useComposeLanguage = ({
42
+ languages,
43
+ text,
44
+ initialLanguage,
45
+ debounceMs = 400,
46
+ }: UseComposeLanguageInput): ComposeLanguageControl => {
47
+ // A caller building this array inline hands over a new identity on every
48
+ // render, and an effect keyed on it would restart its timer forever.
49
+ const candidateKey = languages.join(",");
50
+ const candidates = useMemo(
51
+ () => candidateKey.split(",").filter((tag) => tag !== ""),
52
+ [candidateKey],
53
+ );
54
+ const accountDefault = candidates[0] ?? "en";
55
+
56
+ const [state, setState] = useState<ComposeLanguageState>(() =>
57
+ initialLanguage
58
+ ? { language: initialLanguage, source: "manual" }
59
+ : { language: accountDefault, source: "account" },
60
+ );
61
+
62
+ const settled = state.source === "manual";
63
+
64
+ useEffect(() => {
65
+ if (settled) return;
66
+ const timer = setTimeout(() => {
67
+ const detected = detectComposeLanguage(text, candidates);
68
+ const language = detected ?? accountDefault;
69
+ const source: ComposeLanguageSource = detected ? "detected" : "account";
70
+ setState((current) => {
71
+ if (current.source === "manual") return current;
72
+ if (current.language === language && current.source === source) {
73
+ return current;
74
+ }
75
+ return { language, source };
76
+ });
77
+ }, debounceMs);
78
+ return () => clearTimeout(timer);
79
+ }, [settled, text, candidates, accountDefault, debounceMs]);
80
+
81
+ const choose = useCallback((tag: string) => {
82
+ setState({ language: tag, source: "manual" });
83
+ }, []);
84
+
85
+ return { ...state, choose };
86
+ };
package/src/index.ts CHANGED
@@ -136,6 +136,10 @@ export {
136
136
  type ComposeMode,
137
137
  composeModeLabels,
138
138
  } from "./components/compose-form-shell.js";
139
+ export {
140
+ ComposeLanguageSetting,
141
+ type ComposeLanguageSettingProps,
142
+ } from "./components/compose-language-setting.js";
139
143
  export {
140
144
  DangerZoneSection,
141
145
  type DangerZoneSectionProps,
@@ -658,6 +662,18 @@ export {
658
662
  type CidResolver,
659
663
  } from "./lib/cid-resolver.js";
660
664
  export { cn } from "./lib/cn.js";
665
+ export {
666
+ browserSpellcheckHelp,
667
+ COMPOSE_LANGUAGE_OPTIONS,
668
+ type ComposeLanguageOption,
669
+ defaultComposeLanguages,
670
+ detectionCodeFor,
671
+ languageChipLabel,
672
+ languageLabel,
673
+ primaryLanguageSubtag,
674
+ unwrapLanguage,
675
+ wrapWithLanguage,
676
+ } from "./lib/compose-language.js";
661
677
  export { generateLayoutClampCSS } from "./lib/email-layout-clamp.js";
662
678
  export {
663
679
  classifyEmailRenderTreatment,
@@ -55,6 +55,15 @@ describe("sanitizeAdoptedHtml", () => {
55
55
  assert.match(result, /<div>first<\/div><div>second<\/div>/);
56
56
  });
57
57
 
58
+ it("keeps the language and direction a passage was written in", () => {
59
+ const result = sanitizeAdoptedHtml(
60
+ '<p>Hoi</p><p lang="fr">Bonjour</p><p dir="rtl" lang="ar">مرحبا</p>',
61
+ );
62
+ assert.match(result, /<p lang="fr">Bonjour<\/p>/);
63
+ assert.match(result, /lang="ar"/);
64
+ assert.match(result, /dir="rtl"/);
65
+ });
66
+
58
67
  it("drops script, style and the presentation a receiving client rewrites", () => {
59
68
  const result = sanitizeAdoptedHtml(
60
69
  [
@@ -46,7 +46,19 @@ const ADOPTED_TAGS = [
46
46
  "img",
47
47
  ];
48
48
 
49
- const ADOPTED_ATTR = ["href", "src", "alt", "colspan", "rowspan"];
49
+ // `lang` and `dir` are structure, not presentation: they are what a recipient's
50
+ // client and every screen reader read the language and the writing direction
51
+ // off, and they are what keeps a quoted passage in another language marked as
52
+ // one inside a message written in this one (#686).
53
+ const ADOPTED_ATTR = [
54
+ "href",
55
+ "src",
56
+ "alt",
57
+ "colspan",
58
+ "rowspan",
59
+ "lang",
60
+ "dir",
61
+ ];
50
62
 
51
63
  const LINK_SCHEMES = /^(?:https?:|mailto:)/i;
52
64
  const IMAGE_SCHEMES = /^(?:https:|data:image\/)/i;
@@ -0,0 +1,193 @@
1
+ import assert from "node:assert/strict";
2
+ import { before, describe, it } from "node:test";
3
+ import { data } from "franc-min/data.js";
4
+ import { JSDOM } from "jsdom";
5
+ import {
6
+ browserSpellcheckHelp,
7
+ COMPOSE_LANGUAGE_OPTIONS,
8
+ defaultComposeLanguages,
9
+ detectionCodeFor,
10
+ languageChipLabel,
11
+ languageLabel,
12
+ unwrapLanguage,
13
+ wrapWithLanguage,
14
+ } from "./compose-language.js";
15
+
16
+ before(() => {
17
+ const dom = new JSDOM("");
18
+ globalThis.DOMParser = dom.window.DOMParser;
19
+ });
20
+
21
+ describe("detectionCodeFor", () => {
22
+ it("resolves a region through its language", () => {
23
+ assert.equal(detectionCodeFor("en-GB"), "eng");
24
+ assert.equal(detectionCodeFor("nl"), "nld");
25
+ });
26
+
27
+ it("declines a language nothing can detect", () => {
28
+ assert.equal(detectionCodeFor("ja"), null);
29
+ });
30
+ });
31
+
32
+ describe("languageChipLabel", () => {
33
+ it("drops the region", () => {
34
+ assert.equal(languageChipLabel("en-GB"), "EN");
35
+ });
36
+ });
37
+
38
+ describe("languageLabel", () => {
39
+ it("names a language in its own words", () => {
40
+ assert.equal(languageLabel("nl"), "Nederlands");
41
+ assert.equal(languageLabel("de"), "Deutsch");
42
+ });
43
+
44
+ it("falls back to the tag the platform cannot name", () => {
45
+ assert.equal(languageLabel("qq"), "qq");
46
+ });
47
+
48
+ it("does not throw on a tag a hand-edited setting could hold", () => {
49
+ assert.equal(languageLabel("not a tag"), "not a tag");
50
+ assert.equal(languageLabel(""), "");
51
+ });
52
+ });
53
+
54
+ describe("defaultComposeLanguages", () => {
55
+ it("follows the browser and always offers a second row", () => {
56
+ assert.deepEqual(defaultComposeLanguages(["nl-NL", "nl", "en-US"]), [
57
+ "nl",
58
+ "en",
59
+ ]);
60
+ });
61
+
62
+ it("skips a language detection has no table for", () => {
63
+ assert.deepEqual(defaultComposeLanguages(["ja-JP", "de-DE"]), ["de", "en"]);
64
+ });
65
+
66
+ it("falls back to English when the browser offers nothing usable", () => {
67
+ assert.deepEqual(defaultComposeLanguages([]), ["en"]);
68
+ });
69
+ });
70
+
71
+ describe("browserSpellcheckHelp", () => {
72
+ it("names the setting Chrome keeps it under", () => {
73
+ const help = browserSpellcheckHelp(
74
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
75
+ );
76
+ assert.match(help, /^Chrome checks every language/);
77
+ });
78
+
79
+ it("names macOS for Safari, not Chrome", () => {
80
+ const help = browserSpellcheckHelp(
81
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15",
82
+ );
83
+ assert.match(help, /^macOS decides this/);
84
+ });
85
+
86
+ it("tells a Firefox user the setting is actually used", () => {
87
+ const help = browserSpellcheckHelp(
88
+ "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0",
89
+ );
90
+ assert.match(help, /^Firefox uses this setting/);
91
+ });
92
+
93
+ it("names the keyboard on iOS, where every engine is WebKit", () => {
94
+ const help = browserSpellcheckHelp(
95
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/141.0.0.0 Mobile/15E148 Safari/604.1",
96
+ );
97
+ assert.match(help, /^On iPhone and iPad the keyboard/);
98
+ });
99
+
100
+ it("never claims the app changes the dictionary", () => {
101
+ for (const agent of ["Chrome/141", "Firefox/130", "Safari/605", "curl/8"]) {
102
+ assert.doesNotMatch(browserSpellcheckHelp(agent), /this app|we /i);
103
+ }
104
+ });
105
+ });
106
+
107
+ describe("wrapWithLanguage", () => {
108
+ it("puts the document under one tagged div", () => {
109
+ assert.equal(
110
+ wrapWithLanguage("<p>Hoi</p>", "nl"),
111
+ '<div lang="nl"><p>Hoi</p></div>',
112
+ );
113
+ });
114
+
115
+ it("leaves an empty document empty", () => {
116
+ assert.equal(wrapWithLanguage("", "nl"), "");
117
+ });
118
+
119
+ it("replaces the tag instead of nesting a second wrapper", () => {
120
+ const once = wrapWithLanguage("<p>Hoi</p>", "nl");
121
+ const twice = wrapWithLanguage(once, "de");
122
+ assert.equal(twice, '<div lang="de"><p>Hoi</p></div>');
123
+ assert.equal(wrapWithLanguage(twice, "de"), twice);
124
+ });
125
+
126
+ it("does not adopt a plain div the message happens to start with", () => {
127
+ const wrapped = wrapWithLanguage("<div>Hoi</div>", "nl");
128
+ assert.equal(wrapped, '<div lang="nl"><div>Hoi</div></div>');
129
+ });
130
+
131
+ it("cannot be talked out of the attribute by a hand-edited tag", () => {
132
+ const hostile = 'nl"><script>alert(1)</script><div lang="nl';
133
+ const parsed = new DOMParser().parseFromString(
134
+ wrapWithLanguage("<p>Hoi</p>", hostile),
135
+ "text/html",
136
+ );
137
+ assert.equal(parsed.querySelectorAll("script").length, 0);
138
+ assert.equal(parsed.body.children.length, 1);
139
+ assert.equal(parsed.body.children[0]?.getAttribute("lang"), hostile);
140
+ });
141
+ });
142
+
143
+ describe("unwrapLanguage", () => {
144
+ it("returns the document the editor should reopen on", () => {
145
+ assert.deepEqual(unwrapLanguage('<div lang="nl"><p>Hoi</p></div>'), {
146
+ html: "<p>Hoi</p>",
147
+ language: "nl",
148
+ });
149
+ });
150
+
151
+ it("leaves an untagged document alone", () => {
152
+ assert.deepEqual(unwrapLanguage("<p>Hoi</p>"), {
153
+ html: "<p>Hoi</p>",
154
+ language: null,
155
+ });
156
+ });
157
+
158
+ it("keeps a tagged passage that is not the whole message", () => {
159
+ const html = '<p>Hoi</p><p lang="fr">Bonjour</p>';
160
+ assert.deepEqual(unwrapLanguage(html), { html, language: null });
161
+ });
162
+
163
+ it("round-trips what the wrapper wrote", () => {
164
+ const html = '<p>Hoi</p><p lang="fr">Bonjour</p>';
165
+ assert.deepEqual(unwrapLanguage(wrapWithLanguage(html, "nl")), {
166
+ html,
167
+ language: "nl",
168
+ });
169
+ });
170
+ });
171
+
172
+ describe("COMPOSE_LANGUAGE_OPTIONS", () => {
173
+ it("offers no tag twice", () => {
174
+ const tags = COMPOSE_LANGUAGE_OPTIONS.map((option) => option.tag);
175
+ assert.equal(new Set(tags).size, tags.length);
176
+ });
177
+
178
+ /**
179
+ * Read against `franc-min`'s own trigram tables rather than against the list
180
+ * that produced them: a code the detector has never heard of is a menu row
181
+ * that can be picked by hand and never detected, and a table built the same
182
+ * way as the list would agree with it and prove nothing.
183
+ */
184
+ it("offers only languages the detector has a table for", () => {
185
+ const known = new Set(
186
+ Object.values(data).flatMap((byLanguage) => Object.keys(byLanguage)),
187
+ );
188
+ const unknown = COMPOSE_LANGUAGE_OPTIONS.filter(
189
+ (option) => !known.has(option.detectionCode),
190
+ );
191
+ assert.deepEqual(unknown, []);
192
+ });
193
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The composer's language: which BCP 47 tag a message is being written in,
3
+ * what a browser will and will not do with it, and how it travels on the
4
+ * message.
5
+ *
6
+ * A page cannot choose the browser's spellcheck dictionary. Chrome and Safari
7
+ * ignore `lang` outright; Firefox reads it but only picks among dictionaries
8
+ * the user already installed. Nothing here claims otherwise — what the tag buys
9
+ * is a Firefox dictionary when one is present, a screen-reader voice, and a
10
+ * message the recipient's client can read the language off. See issue #686.
11
+ */
12
+
13
+ /**
14
+ * A language the composer offers, and the ISO 639-3 code `franc-min` knows it
15
+ * by. The two are separate alphabets: the tag is what goes on the document and
16
+ * on the wire, the code is what the detector's trigram tables are keyed on.
17
+ *
18
+ * The list is bounded by what `franc-min` can detect — a language it has no
19
+ * table for could be picked by hand but never detected, which is a menu entry
20
+ * that behaves differently from its neighbours for no reason a user can see.
21
+ */
22
+ export interface ComposeLanguageOption {
23
+ tag: string;
24
+ detectionCode: string;
25
+ }
26
+
27
+ export const COMPOSE_LANGUAGE_OPTIONS: readonly ComposeLanguageOption[] = [
28
+ { tag: "ar", detectionCode: "arb" },
29
+ { tag: "az", detectionCode: "azj" },
30
+ { tag: "be", detectionCode: "bel" },
31
+ { tag: "bg", detectionCode: "bul" },
32
+ { tag: "bs", detectionCode: "bos" },
33
+ { tag: "cs", detectionCode: "ces" },
34
+ { tag: "de", detectionCode: "deu" },
35
+ { tag: "en", detectionCode: "eng" },
36
+ { tag: "es", detectionCode: "spa" },
37
+ { tag: "fa", detectionCode: "pes" },
38
+ { tag: "fr", detectionCode: "fra" },
39
+ { tag: "ha", detectionCode: "hau" },
40
+ { tag: "hi", detectionCode: "hin" },
41
+ { tag: "hr", detectionCode: "hrv" },
42
+ { tag: "hu", detectionCode: "hun" },
43
+ { tag: "id", detectionCode: "ind" },
44
+ { tag: "ig", detectionCode: "ibo" },
45
+ { tag: "it", detectionCode: "ita" },
46
+ { tag: "jv", detectionCode: "jav" },
47
+ { tag: "kk", detectionCode: "kaz" },
48
+ { tag: "mr", detectionCode: "mar" },
49
+ { tag: "ms", detectionCode: "zlm" },
50
+ { tag: "ne", detectionCode: "npi" },
51
+ { tag: "nl", detectionCode: "nld" },
52
+ { tag: "pl", detectionCode: "pol" },
53
+ { tag: "ps", detectionCode: "pbu" },
54
+ { tag: "pt", detectionCode: "por" },
55
+ { tag: "ro", detectionCode: "ron" },
56
+ { tag: "ru", detectionCode: "rus" },
57
+ { tag: "so", detectionCode: "som" },
58
+ { tag: "sr", detectionCode: "srp" },
59
+ { tag: "su", detectionCode: "sun" },
60
+ { tag: "sv", detectionCode: "swe" },
61
+ { tag: "sw", detectionCode: "swh" },
62
+ { tag: "tl", detectionCode: "tgl" },
63
+ { tag: "tr", detectionCode: "tur" },
64
+ { tag: "uk", detectionCode: "ukr" },
65
+ { tag: "ur", detectionCode: "urd" },
66
+ { tag: "vi", detectionCode: "vie" },
67
+ { tag: "yo", detectionCode: "yor" },
68
+ { tag: "zu", detectionCode: "zul" },
69
+ ];
70
+
71
+ /** The language subtag of a BCP 47 tag: `en` out of `en-GB`, lower-cased. */
72
+ export const primaryLanguageSubtag = (tag: string): string =>
73
+ (tag.split("-")[0] ?? "").toLowerCase();
74
+
75
+ const byPrimarySubtag = new Map(
76
+ COMPOSE_LANGUAGE_OPTIONS.map((option) => [option.tag, option]),
77
+ );
78
+
79
+ /**
80
+ * The detector code for a tag, or null when nothing can detect it. A region
81
+ * resolves through its language — no trigram table separates `en-GB` from
82
+ * `en-US`, and pretending one does would put a tag on the message the text
83
+ * never supported.
84
+ */
85
+ export const detectionCodeFor = (tag: string): string | null =>
86
+ byPrimarySubtag.get(primaryLanguageSubtag(tag))?.detectionCode ?? null;
87
+
88
+ const WELL_FORMED_TAG = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
89
+
90
+ /** The tag as the chip shows it: two or three letters, upper case. */
91
+ export const languageChipLabel = (tag: string): string =>
92
+ primaryLanguageSubtag(tag).toUpperCase();
93
+
94
+ /**
95
+ * The language's own name for itself — `Nederlands`, not `Dutch`. A menu of
96
+ * languages is read by someone who reads that language, and an endonym is the
97
+ * entry they recognise without translating it first. Falls back to the tag when
98
+ * the platform has no name for it.
99
+ */
100
+ export const languageLabel = (tag: string): string => {
101
+ // `Intl.DisplayNames` throws a RangeError on anything that is not a
102
+ // well-formed tag, and a stored setting is text a user can have edited.
103
+ if (!WELL_FORMED_TAG.test(tag)) return tag;
104
+ const names = new Intl.DisplayNames([tag], {
105
+ type: "language",
106
+ fallback: "none",
107
+ });
108
+ const named = names.of(tag);
109
+ if (!named) return tag;
110
+ return named.charAt(0).toLocaleUpperCase(tag) + named.slice(1);
111
+ };
112
+
113
+ /**
114
+ * The languages an account writes in when it has not said. The browser already
115
+ * holds an ordered answer; `en` follows it, so a browser set to one language
116
+ * nothing can detect still leaves the chip something to show.
117
+ */
118
+ export const defaultComposeLanguages = (
119
+ locales: readonly string[],
120
+ ): string[] => {
121
+ const known = locales.filter((locale) => detectionCodeFor(locale) !== null);
122
+ const tags = known.map(primaryLanguageSubtag);
123
+ const unique = [...new Set(tags)];
124
+ if (!unique.includes("en")) unique.push("en");
125
+ return unique;
126
+ };
127
+
128
+ /**
129
+ * The one sentence at the foot of the language menu naming where the browser
130
+ * keeps the setting that does fix spelling. A page cannot link to
131
+ * `chrome://settings/languages`, so it is text; and reading the user agent to
132
+ * name where a setting lives is not feature detection, because there is nothing
133
+ * to detect.
134
+ */
135
+ export const browserSpellcheckHelp = (userAgent: string): string => {
136
+ if (/iPhone|iPad|iPod/.test(userAgent)) {
137
+ return "On iPhone and iPad the keyboard decides this. Add the language under Settings, then General, then Keyboard.";
138
+ }
139
+ if (/Edg\//.test(userAgent)) {
140
+ return "Edge checks every language you add under Settings, then Languages. Adding one there checks it alongside the others.";
141
+ }
142
+ if (/Firefox\//.test(userAgent)) {
143
+ return "Firefox uses this setting. Right-click the message to add a dictionary for it.";
144
+ }
145
+ if (/Chrome\/|Chromium\//.test(userAgent)) {
146
+ return "Chrome checks every language you add under Settings, then Languages. Adding one there checks it alongside the others.";
147
+ }
148
+ if (/Safari\//.test(userAgent)) {
149
+ return "macOS decides this under Keyboard, then Text Input, then Spelling. Automatic by Language covers every language enabled there.";
150
+ }
151
+ return "Your browser decides which dictionaries it checks. Add this language in its own language settings to have it spellchecked.";
152
+ };
153
+
154
+ const parseHtml = (html: string): Document =>
155
+ new DOMParser().parseFromString(html, "text/html");
156
+
157
+ const singleRootElement = (body: HTMLElement): Element | null => {
158
+ const elements = [...body.children];
159
+ if (elements.length !== 1) return null;
160
+ const only = elements[0];
161
+ if (!only) return null;
162
+ const hasStrayText = [...body.childNodes].some(
163
+ (node) => node !== only && (node.textContent ?? "").trim() !== "",
164
+ );
165
+ return hasStrayText ? null : only;
166
+ };
167
+
168
+ /**
169
+ * The outgoing HTML under one `<div lang>`, so the recipient's client reads the
170
+ * language off the message rather than guessing it.
171
+ *
172
+ * Re-wrapping is idempotent: a document that already sits under a language
173
+ * wrapper has that wrapper's tag replaced. Autosave runs this on every keystroke
174
+ * batch, and a draft that gained a `<div>` per save would arrive nested forty
175
+ * deep.
176
+ */
177
+ export const wrapWithLanguage = (html: string, tag: string): string => {
178
+ if (html === "") return "";
179
+ const document = parseHtml(html);
180
+ const existing = singleRootElement(document.body);
181
+ if (existing?.tagName === "DIV" && existing.hasAttribute("lang")) {
182
+ existing.setAttribute("lang", tag);
183
+ return document.body.innerHTML;
184
+ }
185
+ // Built through the DOM rather than by string concatenation: a stored
186
+ // setting is text a user can have edited, and a tag carrying a quote would
187
+ // otherwise close the attribute and put markup into the message.
188
+ const wrapper = document.createElement("div");
189
+ wrapper.setAttribute("lang", tag);
190
+ while (document.body.firstChild) wrapper.append(document.body.firstChild);
191
+ document.body.append(wrapper);
192
+ return document.body.innerHTML;
193
+ };
194
+
195
+ /** What `wrapWithLanguage` wrote, taken back apart for the editor to reopen on. */
196
+ export const unwrapLanguage = (
197
+ html: string,
198
+ ): { html: string; language: string | null } => {
199
+ if (html === "") return { html, language: null };
200
+ const document = parseHtml(html);
201
+ const root = singleRootElement(document.body);
202
+ if (root?.tagName !== "DIV" || !root.hasAttribute("lang")) {
203
+ return { html, language: null };
204
+ }
205
+ return { html: root.innerHTML, language: root.getAttribute("lang") };
206
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Detection restricted to the account's own languages. The restriction is the
3
+ * whole reason a 53 kB trigram table is good enough: `franc` scores 87.1% on
4
+ * one sentence over all 414 languages and 97.3% over six.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { describe, it } from "node:test";
8
+ import { detectComposeLanguage } from "./detect-compose-language.js";
9
+
10
+ const DUTCH =
11
+ "Beste Anna, de vergadering van donderdag gaat niet door. Ik stuur je morgen een nieuw voorstel.";
12
+ const ENGLISH =
13
+ "Hi Anna, Thursday's meeting is off. I will send you a new proposal tomorrow.";
14
+ const GERMAN =
15
+ "Hallo Anna, die Besprechung am Donnerstag fällt aus. Ich schicke dir morgen einen neuen Vorschlag.";
16
+ const FRENCH =
17
+ "Bonjour Anna, la réunion de jeudi est annulée. Je vous enverrai une nouvelle proposition demain.";
18
+
19
+ const CANDIDATES = ["nl", "en", "de"];
20
+
21
+ describe("detectComposeLanguage", () => {
22
+ it("picks each candidate out of the others", () => {
23
+ assert.equal(detectComposeLanguage(DUTCH, CANDIDATES), "nl");
24
+ assert.equal(detectComposeLanguage(ENGLISH, CANDIDATES), "en");
25
+ assert.equal(detectComposeLanguage(GERMAN, CANDIDATES), "de");
26
+ });
27
+
28
+ it("stays inside the candidate set", () => {
29
+ const detected = detectComposeLanguage(FRENCH, CANDIDATES);
30
+ assert.ok(detected === null || CANDIDATES.includes(detected));
31
+ });
32
+
33
+ it("declines a greeting line", () => {
34
+ assert.equal(detectComposeLanguage("Hoi Anna,", CANDIDATES), null);
35
+ assert.equal(detectComposeLanguage(" ", CANDIDATES), null);
36
+ });
37
+
38
+ it("declines when there is nothing to choose between", () => {
39
+ assert.equal(detectComposeLanguage(DUTCH, ["nl"]), null);
40
+ assert.equal(detectComposeLanguage(DUTCH, []), null);
41
+ });
42
+
43
+ it("ignores a configured language nothing can detect", () => {
44
+ assert.equal(detectComposeLanguage(DUTCH, ["nl", "ja"]), null);
45
+ assert.equal(detectComposeLanguage(DUTCH, ["nl", "ja", "en"]), "nl");
46
+ });
47
+
48
+ it("resolves a regional tag through its language", () => {
49
+ assert.equal(detectComposeLanguage(ENGLISH, ["nl", "en-GB"]), "en-GB");
50
+ });
51
+ });