@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.
- package/package.json +2 -1
- package/src/components/button.tsx +4 -0
- package/src/components/compose-language-chip.stories.tsx +136 -0
- package/src/components/compose-language-chip.tsx +151 -0
- package/src/components/compose-language-setting.stories.tsx +82 -0
- package/src/components/compose-language-setting.tsx +106 -0
- package/src/components/plain-text-editor.stories.tsx +14 -4
- package/src/components/plain-text-editor.tsx +13 -1
- package/src/components/rich-text-editor.stories.tsx +36 -12
- package/src/components/rich-text-editor.tsx +8 -0
- package/src/components/rich-text-toolbar.tsx +8 -4
- package/src/components/use-compose-language.ts +86 -0
- package/src/index.ts +16 -0
- package/src/lib/adopted-html.test.ts +9 -0
- package/src/lib/adopted-html.ts +13 -1
- package/src/lib/compose-language.test.ts +193 -0
- package/src/lib/compose-language.ts +206 -0
- package/src/lib/detect-compose-language.test.ts +51 -0
- package/src/lib/detect-compose-language.ts +42 -0
- package/src/rich-text.ts +15 -0
|
@@ -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
|
|
72
|
-
*
|
|
73
|
-
* Shift+Tab out of the body reaches
|
|
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 &&
|
|
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
|
[
|
package/src/lib/adopted-html.ts
CHANGED
|
@@ -46,7 +46,19 @@ const ADOPTED_TAGS = [
|
|
|
46
46
|
"img",
|
|
47
47
|
];
|
|
48
48
|
|
|
49
|
-
|
|
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
|
+
});
|