@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.
- 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/compose-mode-toggle.tsx +35 -0
- package/src/components/plain-text-editor.mount.test.ts +238 -0
- package/src/components/plain-text-editor.stories.tsx +237 -0
- package/src/components/plain-text-editor.tsx +190 -0
- package/src/components/rich-text-document.ts +111 -3
- package/src/components/rich-text-editor.stories.tsx +100 -0
- package/src/components/rich-text-editor.tsx +12 -1
- package/src/components/rich-text-formatting.test.ts +153 -0
- package/src/components/rich-text-markdown.test.ts +131 -0
- package/src/components/rich-text-markdown.ts +155 -0
- package/src/components/rich-text-toolbar.tsx +72 -46
- package/src/components/rich-text-value.ts +11 -1
- 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 +32 -3
- package/src/tokens.css +4 -0
|
@@ -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={`
|
|
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
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
<
|
|
193
|
-
|
|
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 = {
|
|
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
|
[
|
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
|
+
});
|