@remit/ui 0.0.100 → 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.
@@ -0,0 +1,155 @@
1
+ import {
2
+ $convertFromMarkdownString,
3
+ $convertToMarkdownString,
4
+ type ElementTransformer,
5
+ isTableRowDivider,
6
+ TRANSFORMERS,
7
+ type Transformer,
8
+ } from "@lexical/markdown";
9
+ import {
10
+ $createTableCellNode,
11
+ $createTableNode,
12
+ $createTableRowNode,
13
+ $isTableCellNode,
14
+ $isTableNode,
15
+ $isTableRowNode,
16
+ TableCellHeaderStates,
17
+ TableCellNode,
18
+ TableNode,
19
+ TableRowNode,
20
+ } from "@lexical/table";
21
+ import { $isTextNode, type ElementNode, type LexicalNode } from "lexical";
22
+
23
+ const TABLE_ROW = /^\|(.+)\|\s*$/;
24
+
25
+ /**
26
+ * A cell is a document of its own, so its content goes through the same
27
+ * transformers. A newline or a bare pipe inside one would end the row early,
28
+ * which is the difference between a table and a wall of broken syntax.
29
+ */
30
+ const exportCell = (cell: TableCellNode): string =>
31
+ $convertToMarkdownString(COMPOSE_TRANSFORMERS, cell)
32
+ .replace(/\n+/g, " ")
33
+ .replace(/\|/g, "\\|")
34
+ .trim();
35
+
36
+ const splitRow = (line: string): string[] => {
37
+ const inner = line.trim().replace(/^\|/, "").replace(/\|$/, "");
38
+ const cells: string[] = [];
39
+ let current = "";
40
+ for (let index = 0; index < inner.length; index++) {
41
+ const character = inner[index];
42
+ if (character === "\\" && inner[index + 1] === "|") {
43
+ current += "|";
44
+ index++;
45
+ continue;
46
+ }
47
+ if (character === "|") {
48
+ cells.push(current);
49
+ current = "";
50
+ continue;
51
+ }
52
+ current += character;
53
+ }
54
+ cells.push(current);
55
+ return cells;
56
+ };
57
+
58
+ const $createCell = (text: string): TableCellNode => {
59
+ const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS);
60
+ $convertFromMarkdownString(text.trim(), COMPOSE_TRANSFORMERS, cell);
61
+ return cell;
62
+ };
63
+
64
+ const $createRow = (texts: string[], columns: number): TableRowNode => {
65
+ const row = $createTableRowNode();
66
+ for (let index = 0; index < columns; index++) {
67
+ row.append($createCell(texts[index] ?? ""));
68
+ }
69
+ return row;
70
+ };
71
+
72
+ const columnsOf = (table: TableNode): number => {
73
+ const first = table.getFirstChild();
74
+ return $isTableRowNode(first) ? first.getChildrenSize() : 0;
75
+ };
76
+
77
+ const $markFirstRowAsHeader = (table: TableNode): void => {
78
+ const first = table.getFirstChild();
79
+ if (!$isTableRowNode(first)) return;
80
+ for (const cell of first.getChildren()) {
81
+ if (!$isTableCellNode(cell)) continue;
82
+ cell.setHeaderStyles(TableCellHeaderStates.ROW, TableCellHeaderStates.ROW);
83
+ }
84
+ };
85
+
86
+ /**
87
+ * `@lexical/markdown` 0.49 exports `isTableRowDivider` but no table
88
+ * transformer, so the composer supplies one over `@lexical/table` nodes. One
89
+ * list drives Markdown export, Markdown import and the cell round trip, which
90
+ * is what keeps a table that left as pipes coming back as a table.
91
+ *
92
+ * Import runs a line at a time, each line arriving as a paragraph. A row
93
+ * appended to the table its predecessor left behind is how the rows join up.
94
+ * A divider carries no content of its own: it says the row above is a header.
95
+ */
96
+ export const TABLE: ElementTransformer = {
97
+ dependencies: [TableNode, TableRowNode, TableCellNode],
98
+ export: (node: LexicalNode): string | null => {
99
+ if (!$isTableNode(node)) return null;
100
+ const lines: string[] = [];
101
+ for (const row of node.getChildren()) {
102
+ if (!$isTableRowNode(row)) continue;
103
+ const cells = row.getChildren().filter($isTableCellNode);
104
+ if (cells.length === 0) continue;
105
+ lines.push(`| ${cells.map(exportCell).join(" | ")} |`);
106
+ // GFM only reads a block of pipe rows as a table when a divider follows
107
+ // the first one, so it is written whether or not that row was a header.
108
+ // Without it a recipient sees the pipes and no table.
109
+ if (lines.length === 1) {
110
+ lines.push(`| ${cells.map(() => "---").join(" | ")} |`);
111
+ }
112
+ }
113
+ return lines.length === 0 ? null : lines.join("\n");
114
+ },
115
+ regExp: TABLE_ROW,
116
+ replace: (parentNode: ElementNode, children, match): void => {
117
+ const line = match[0].trimEnd();
118
+ const previous = parentNode.getPreviousSibling();
119
+
120
+ if (isTableRowDivider(line)) {
121
+ if ($isTableNode(previous)) {
122
+ $markFirstRowAsHeader(previous);
123
+ parentNode.remove();
124
+ return;
125
+ }
126
+ // A divider with no rows above it is not a table. The import emptied
127
+ // this line's text node before calling here, so declining the match
128
+ // would drop the characters rather than leave them alone.
129
+ const first = children[0];
130
+ if ($isTextNode(first)) first.setTextContent(line);
131
+ return;
132
+ }
133
+
134
+ const texts = splitRow(line);
135
+ if ($isTableNode(previous)) {
136
+ previous.append($createRow(texts, columnsOf(previous)));
137
+ parentNode.remove();
138
+ return;
139
+ }
140
+
141
+ const table = $createTableNode();
142
+ table.append($createRow(texts, texts.length));
143
+ parentNode.replace(table);
144
+ },
145
+ type: "element",
146
+ };
147
+
148
+ /**
149
+ * The composer's own transformer set. Everything that reads or writes Markdown
150
+ * in compose uses this one list — the plain alternative on a rich send, the
151
+ * body of a plain send, the down-conversion of a paste, and both directions of
152
+ * the mode switch — so the conversions are inverses rather than four
153
+ * independent best efforts.
154
+ */
155
+ export const COMPOSE_TRANSFORMERS: Transformer[] = [TABLE, ...TRANSFORMERS];
@@ -38,7 +38,7 @@ const ToolbarButton = ({
38
38
  title={title}
39
39
  aria-label={title}
40
40
  aria-pressed={isActive}
41
- className={`p-1.5 rounded transition-colors ${
41
+ className={`inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded transition-colors ${
42
42
  isActive
43
43
  ? "text-fg bg-accent-2-soft"
44
44
  : "text-fg-muted hover:text-fg hover:bg-surface-raised"
@@ -66,7 +66,16 @@ const INITIAL_STATE: ToolbarState = {
66
66
  canRedo: false,
67
67
  };
68
68
 
69
- export const RichTextToolbar = () => {
69
+ export interface RichTextToolbarProps {
70
+ /**
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
+ */
75
+ trailing?: React.ReactNode;
76
+ }
77
+
78
+ export const RichTextToolbar = ({ trailing }: RichTextToolbarProps) => {
70
79
  const [editor] = useLexicalComposerContext();
71
80
  const [state, setState] = useState<ToolbarState>(INITIAL_STATE);
72
81
  const [linkDraft, setLinkDraft] = useState<string | null>(null);
@@ -146,51 +155,68 @@ export const RichTextToolbar = () => {
146
155
  };
147
156
 
148
157
  return (
149
- <div className="border-b border-line">
150
- <div className="flex items-center gap-0.5 px-3 py-1">
151
- <ToolbarButton
152
- isActive={state.bold}
153
- onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")}
154
- title="Bold (Ctrl+B)"
155
- >
156
- <Bold className="size-4" />
157
- </ToolbarButton>
158
- <ToolbarButton
159
- isActive={state.italic}
160
- onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic")}
161
- title="Italic (Ctrl+I)"
162
- >
163
- <Italic className="size-4" />
164
- </ToolbarButton>
165
- <ToolbarButton
166
- isActive={state.link !== null}
167
- onClick={() => setLinkDraft(state.link ?? "https://")}
168
- title="Link (Ctrl+K)"
169
- >
170
- <Link className="size-4" />
171
- </ToolbarButton>
172
- <ToolbarButton
173
- isActive={state.quote}
174
- onClick={toggleQuote}
175
- title="Blockquote"
176
- >
177
- <Quote className="size-4" />
178
- </ToolbarButton>
179
- <div className="mx-1.5 h-4 w-px bg-line" />
180
- <ToolbarButton
181
- isActive={false}
182
- onClick={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}
183
- title="Undo (Ctrl+Z)"
184
- >
185
- <Undo2 className={`size-4 ${state.canUndo ? "" : "opacity-40"}`} />
186
- </ToolbarButton>
187
- <ToolbarButton
188
- isActive={false}
189
- onClick={() => editor.dispatchCommand(REDO_COMMAND, undefined)}
190
- title="Redo (Ctrl+Y)"
158
+ // The toolbar and the body share one scroller, so twenty lines of typing
159
+ // would carry the toolbar off the top of the compose window with them.
160
+ <div className="sticky top-0 z-10 border-b border-line bg-canvas">
161
+ <div className="flex items-center gap-2 px-3 py-1">
162
+ {/* `min-w-0`, without which a flex child refuses to shrink below its
163
+ content and pushes the trailing control off the edge instead of
164
+ scrolling. */}
165
+ <div
166
+ data-testid="compose-format-cluster"
167
+ className="flex min-w-0 items-center gap-0.5 overflow-x-auto"
191
168
  >
192
- <Redo2 className={`size-4 ${state.canRedo ? "" : "opacity-40"}`} />
193
- </ToolbarButton>
169
+ <ToolbarButton
170
+ isActive={state.bold}
171
+ onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")}
172
+ title="Bold (Ctrl+B)"
173
+ >
174
+ <Bold className="size-4" />
175
+ </ToolbarButton>
176
+ <ToolbarButton
177
+ isActive={state.italic}
178
+ onClick={() =>
179
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic")
180
+ }
181
+ title="Italic (Ctrl+I)"
182
+ >
183
+ <Italic className="size-4" />
184
+ </ToolbarButton>
185
+ <ToolbarButton
186
+ isActive={state.link !== null}
187
+ onClick={() => setLinkDraft(state.link ?? "https://")}
188
+ title="Link (Ctrl+K)"
189
+ >
190
+ <Link className="size-4" />
191
+ </ToolbarButton>
192
+ <ToolbarButton
193
+ isActive={state.quote}
194
+ onClick={toggleQuote}
195
+ title="Blockquote"
196
+ >
197
+ <Quote className="size-4" />
198
+ </ToolbarButton>
199
+ <div className="mx-1.5 h-4 w-px shrink-0 bg-line" />
200
+ <ToolbarButton
201
+ isActive={false}
202
+ onClick={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}
203
+ title="Undo (Ctrl+Z)"
204
+ >
205
+ <Undo2 className={`size-4 ${state.canUndo ? "" : "opacity-40"}`} />
206
+ </ToolbarButton>
207
+ <ToolbarButton
208
+ isActive={false}
209
+ onClick={() => editor.dispatchCommand(REDO_COMMAND, undefined)}
210
+ title="Redo (Ctrl+Y)"
211
+ >
212
+ <Redo2 className={`size-4 ${state.canRedo ? "" : "opacity-40"}`} />
213
+ </ToolbarButton>
214
+ </div>
215
+ {trailing && (
216
+ <div className="ml-auto flex shrink-0 items-center gap-1">
217
+ {trailing}
218
+ </div>
219
+ )}
194
220
  </div>
195
221
  {linkDraft !== null && (
196
222
  <div className="flex items-center gap-2 border-t border-line px-3 py-1.5">
@@ -3,12 +3,22 @@
3
3
  * not the editor's own JSON, which changes shape between Lexical versions.
4
4
  * `text` is the plain alternative, as Markdown over the same document.
5
5
  *
6
+ * `formatting` is the inventory of node types and text formats in the document
7
+ * that plain text cannot carry, sorted, empty for a document of ordinary
8
+ * paragraphs. It is reported rather than reduced to a flag so the caller can
9
+ * both decide and say what is at stake.
10
+ *
6
11
  * Kept apart from the editor so a caller can name the shape without pulling
7
12
  * the editor, and its dependencies, out of their own chunk.
8
13
  */
9
14
  export interface RichTextValue {
10
15
  html: string;
11
16
  text: string;
17
+ formatting: readonly string[];
12
18
  }
13
19
 
14
- export const EMPTY_RICH_TEXT: RichTextValue = { html: "", text: "" };
20
+ export const EMPTY_RICH_TEXT: RichTextValue = {
21
+ html: "",
22
+ text: "",
23
+ formatting: [],
24
+ };
@@ -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
+ });