@remit/ui 0.0.99 → 0.0.101

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.
@@ -1,6 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { Paperclip, Star } from "lucide-react";
3
3
  import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
4
+ import { AttachmentList } from "./attachment-list.js";
5
+ import { MessageBodyView } from "./message-body-view.js";
4
6
  import {
5
7
  CollapsedMessage,
6
8
  ExpandedMessage,
@@ -156,7 +158,11 @@ export const CollapsedRowComposed: StoryObj<typeof CollapsedMessage> = {
156
158
  >
157
159
  <Star className="size-3 fill-current" />
158
160
  </button>
159
- <Paperclip className="size-3 text-fg-subtle" />
161
+ <Paperclip
162
+ className="size-3 text-fg-subtle"
163
+ role="img"
164
+ aria-label="Has an attachment"
165
+ />
160
166
  <span className="text-2xs text-fg-subtle tabular-nums">
161
167
  Today, 08:42
162
168
  </span>
@@ -180,7 +186,6 @@ export const ExpandedRowComposed: StoryObj<typeof ExpandedMessage> = {
180
186
  indicators={
181
187
  <div className="mt-0.5 flex items-center gap-1">
182
188
  <Star className="size-3.5 fill-current text-warning" />
183
- <Paperclip className="size-3.5 text-fg-subtle" />
184
189
  </div>
185
190
  }
186
191
  actionMenu={
@@ -192,3 +197,54 @@ export const ExpandedRowComposed: StoryObj<typeof ExpandedMessage> = {
192
197
  </div>
193
198
  ),
194
199
  };
200
+
201
+ /**
202
+ * The expanded row as `MessageCard` composes it when the message carries files
203
+ * (#683): body first, attachment list under it. The indicators row holds no
204
+ * paperclip — the list below is the affordance, and a second paperclip beside
205
+ * the live star button only reads as a control that does nothing.
206
+ */
207
+ export const ExpandedRowWithAttachments: StoryObj<typeof ExpandedMessage> = {
208
+ render: () => (
209
+ <div className="max-w-3xl border border-line">
210
+ <ExpandedMessage
211
+ message={row}
212
+ to={<>to Alex Rivera and 2 others</>}
213
+ indicators={
214
+ <div className="mt-0.5 flex items-center gap-1">
215
+ <Star className="size-3.5 fill-current text-warning" />
216
+ </div>
217
+ }
218
+ body={
219
+ <div className="mt-3">
220
+ <MessageBodyView
221
+ html={row.bodyHtml}
222
+ category="personal"
223
+ allowImages
224
+ />
225
+ <AttachmentList
226
+ className="mt-4 px-2 lg:px-0"
227
+ attachments={[
228
+ {
229
+ attachmentId: "part-2",
230
+ filename: "Q3 board pack.pdf",
231
+ typeLabel: "PDF",
232
+ sizeOctets: 2_411_724,
233
+ download: { status: "idle" },
234
+ },
235
+ {
236
+ attachmentId: "part-3",
237
+ filename: "headcount.csv",
238
+ typeLabel: "CSV",
239
+ sizeOctets: 4_180,
240
+ download: { status: "idle" },
241
+ },
242
+ ]}
243
+ onDownload={(id) => alert(`Download ${id}`)}
244
+ />
245
+ </div>
246
+ }
247
+ />
248
+ </div>
249
+ ),
250
+ };
@@ -1,7 +1,21 @@
1
1
  import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html";
2
- import { $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown";
3
- import type { LexicalEditor, LexicalNode } from "lexical";
2
+ import {
3
+ $convertFromMarkdownString,
4
+ $convertToMarkdownString,
5
+ } from "@lexical/markdown";
6
+ import {
7
+ $getRoot,
8
+ $insertNodes,
9
+ $isElementNode,
10
+ $isTextNode,
11
+ createEditor,
12
+ type LexicalEditor,
13
+ type LexicalNode,
14
+ type TextFormatType,
15
+ } from "lexical";
4
16
  import { sanitizeAdoptedHtml } from "../lib/adopted-html.js";
17
+ import { COMPOSE_TRANSFORMERS } from "./rich-text-markdown.js";
18
+ import { RICH_TEXT_NODES } from "./rich-text-nodes.js";
5
19
  import type { RichTextValue } from "./rich-text-value.js";
6
20
 
7
21
  export const $adoptHtml = (
@@ -15,6 +29,55 @@ export const $adoptHtml = (
15
29
  return $generateNodesFromDOM(editor, document);
16
30
  };
17
31
 
32
+ /**
33
+ * What survives a conversion to Markdown unchanged: a run of paragraphs, the
34
+ * line breaks inside them, and unformatted characters. Anything else is
35
+ * formatting that becomes visible syntax, or is lost outright.
36
+ */
37
+ const PLAIN_NODE_TYPES = new Set(["root", "paragraph", "text", "linebreak"]);
38
+
39
+ const TEXT_FORMATS: readonly TextFormatType[] = [
40
+ "bold",
41
+ "italic",
42
+ "strikethrough",
43
+ "underline",
44
+ "code",
45
+ "subscript",
46
+ "superscript",
47
+ "highlight",
48
+ ];
49
+
50
+ /**
51
+ * Every node type and text format in the document that a plain-text
52
+ * representation cannot carry, as an inventory rather than a verdict — the
53
+ * caller decides what to do with a document that holds any.
54
+ *
55
+ * A comparison of the Markdown export against the plain text would be the
56
+ * obvious rule and is the wrong one in both directions. `@lexical/markdown`
57
+ * ships no underline transformer, so an underlined word exports identical to
58
+ * its own characters and would be destroyed in silence; a trailing empty
59
+ * paragraph makes the two strings differ and would raise a warning over
60
+ * ordinary prose.
61
+ */
62
+ export const $documentFormatting = (): string[] => {
63
+ const found = new Set<string>();
64
+
65
+ const visit = (node: LexicalNode): void => {
66
+ const type = node.getType();
67
+ if (!PLAIN_NODE_TYPES.has(type)) found.add(type);
68
+ if ($isTextNode(node)) {
69
+ for (const format of TEXT_FORMATS) {
70
+ if (node.hasFormat(format)) found.add(format);
71
+ }
72
+ }
73
+ if (!$isElementNode(node)) return;
74
+ for (const child of node.getChildren()) visit(child);
75
+ };
76
+
77
+ visit($getRoot());
78
+ return [...found].sort();
79
+ };
80
+
18
81
  /**
19
82
  * Lexical's export writes the editor's own theme onto every element — the app's
20
83
  * Tailwind class names, a `white-space` span around each text run, computed
@@ -24,5 +87,50 @@ export const $adoptHtml = (
24
87
  */
25
88
  export const $readRichText = (editor: LexicalEditor): RichTextValue => ({
26
89
  html: sanitizeAdoptedHtml($generateHtmlFromNodes(editor, null)),
27
- text: $convertToMarkdownString(TRANSFORMERS),
90
+ text: $convertToMarkdownString(COMPOSE_TRANSFORMERS),
91
+ formatting: $documentFormatting(),
28
92
  });
93
+
94
+ /**
95
+ * A document held only long enough to convert one representation into the
96
+ * other. It is never attached to the DOM, so it has no theme, no history and
97
+ * no plugins — only the node types the composer registers, which is what makes
98
+ * the result the same document the editor would have produced.
99
+ */
100
+ const scratchEditor = (): LexicalEditor =>
101
+ createEditor({
102
+ namespace: "compose-conversion",
103
+ nodes: [...RICH_TEXT_NODES],
104
+ onError: (error) => {
105
+ throw error;
106
+ },
107
+ });
108
+
109
+ /** Adopted HTML as Markdown: a table as pipe rows, a heading as `##`. */
110
+ export const htmlToMarkdown = (html: string): string => {
111
+ const editor = scratchEditor();
112
+ editor.update(
113
+ () => {
114
+ const root = $getRoot();
115
+ root.clear();
116
+ root.select();
117
+ $insertNodes($adoptHtml(editor, html));
118
+ },
119
+ { discrete: true },
120
+ );
121
+ return editor.read(() => $convertToMarkdownString(COMPOSE_TRANSFORMERS));
122
+ };
123
+
124
+ /** Markdown as the HTML a rich draft stores and sends. */
125
+ export const markdownToHtml = (markdown: string): string => {
126
+ const editor = scratchEditor();
127
+ editor.update(
128
+ () => {
129
+ $convertFromMarkdownString(markdown, COMPOSE_TRANSFORMERS);
130
+ },
131
+ { discrete: true },
132
+ );
133
+ return editor.read(() =>
134
+ sanitizeAdoptedHtml($generateHtmlFromNodes(editor, null)),
135
+ );
136
+ };
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { expect, userEvent } from "storybook/test";
3
3
  import { sanitizeAdoptedHtml } from "../lib/adopted-html.js";
4
+ import { ComposeModeToggle } from "./compose-mode-toggle.js";
4
5
  import { RichTextEditor } from "./rich-text-editor.js";
5
6
 
6
7
  /**
@@ -102,3 +103,78 @@ export const ClickBelowTheText: Story = {
102
103
  await expect(editable).toHaveFocus();
103
104
  },
104
105
  };
106
+
107
+ const modeToggle = <ComposeModeToggle mode="rich" onToggle={() => undefined} />;
108
+
109
+ /** The toolbar as compose ships it: the formatting cluster, then the mode. */
110
+ export const ToolbarInRich: Story = {
111
+ name: "Toolbar with the mode toggle",
112
+ args: { initialHtml: RICH_DOCUMENT, trailing: modeToggle },
113
+ };
114
+
115
+ /**
116
+ * At 390 the formatting cluster runs out of room. It scrolls inside its own
117
+ * strip and the toggle stays pinned at the right edge, rather than the cluster
118
+ * pushing the toggle off the screen — which is what a flex child without
119
+ * `min-w-0` does.
120
+ */
121
+ export const NarrowToolbar: Story = {
122
+ name: "Toolbar at 390",
123
+ args: { initialHtml: RICH_DOCUMENT, trailing: modeToggle },
124
+ decorators: [
125
+ (Story) => (
126
+ <div
127
+ data-testid="body-area"
128
+ className="flex h-[420px] w-[390px] flex-col overflow-auto rounded-md border border-line bg-canvas"
129
+ >
130
+ <Story />
131
+ </div>
132
+ ),
133
+ ],
134
+ play: async ({ canvasElement }) => {
135
+ const frame = canvasElement.querySelector<HTMLElement>(
136
+ "[data-testid=body-area]",
137
+ );
138
+ const cluster = canvasElement.querySelector<HTMLElement>(
139
+ "[data-testid=compose-format-cluster]",
140
+ );
141
+ const toggle = canvasElement.querySelector<HTMLElement>(
142
+ "[data-testid=compose-mode-toggle]",
143
+ );
144
+ if (!frame || !cluster || !toggle)
145
+ throw new Error("the toolbar is not mounted");
146
+
147
+ await expect(cluster.scrollWidth).toBeGreaterThan(cluster.clientWidth);
148
+ await expect(toggle.getBoundingClientRect().right).toBeLessThanOrEqual(
149
+ frame.getBoundingClientRect().right + 1,
150
+ );
151
+ },
152
+ };
153
+
154
+ /**
155
+ * The toolbar and the body share one scroller, so twenty lines of typing would
156
+ * carry the toolbar off the top with them. It stays at the top of the body
157
+ * while the text moves under it.
158
+ */
159
+ export const StickyToolbar: Story = {
160
+ name: "Toolbar over a scrolled body",
161
+ args: {
162
+ initialHtml: `${RICH_DOCUMENT}${"<p>Another line of the message.</p>".repeat(30)}`,
163
+ trailing: modeToggle,
164
+ },
165
+ play: async ({ canvasElement }) => {
166
+ const frame = canvasElement.querySelector<HTMLElement>(
167
+ "[data-testid=body-area]",
168
+ );
169
+ const toggle = canvasElement.querySelector<HTMLElement>(
170
+ "[data-testid=compose-mode-toggle]",
171
+ );
172
+ if (!frame || !toggle) throw new Error("the toolbar is not mounted");
173
+
174
+ frame.scrollTop = 400;
175
+ await expect(frame.scrollTop).toBeGreaterThan(0);
176
+ await expect(
177
+ toggle.getBoundingClientRect().top - frame.getBoundingClientRect().top,
178
+ ).toBeLessThan(60);
179
+ },
180
+ };
@@ -36,6 +36,8 @@ export interface RichTextEditorProps {
36
36
  autoFocus?: boolean;
37
37
  placeholder?: string;
38
38
  ariaLabel?: string;
39
+ /** Pinned to the right of the toolbar strip. The mode toggle rides here. */
40
+ trailing?: React.ReactNode;
39
41
  }
40
42
 
41
43
  /**
@@ -161,6 +163,7 @@ export const RichTextEditor = ({
161
163
  autoFocus = false,
162
164
  placeholder = "Write your message…",
163
165
  ariaLabel = "Message body",
166
+ trailing,
164
167
  }: RichTextEditorProps) => (
165
168
  <LexicalComposer
166
169
  initialConfig={{
@@ -177,7 +180,7 @@ export const RichTextEditor = ({
177
180
  height of its own text. What is under the last line is the document, so
178
181
  a click there reaches it instead of an unfocusable parent. */}
179
182
  <div className="flex shrink-0 grow flex-col">
180
- <RichTextToolbar />
183
+ <RichTextToolbar trailing={trailing} />
181
184
  <div className="relative flex shrink-0 grow flex-col">
182
185
  <RichTextPlugin
183
186
  contentEditable={
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The rule that decides whether a message's formatting is destroyed without
3
+ * asking. It fires on what the document holds, not on a comparison of the
4
+ * Markdown export against the plain text: `@lexical/markdown` ships no
5
+ * underline transformer, so an underlined word exports identical to its own
6
+ * characters and a string comparison would switch in silence over exactly the
7
+ * document this warning exists for. In the other direction a trailing empty
8
+ * paragraph makes the two strings differ, and the same comparison would
9
+ * interrupt the ordinary prose it is meant to leave alone.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { before, describe, it } from "node:test";
13
+ import type { JSDOM } from "jsdom";
14
+ import type { LexicalEditor } from "lexical";
15
+
16
+ interface Row {
17
+ what: string;
18
+ html: string;
19
+ /** What the switch does: warn first, or switch in silence. */
20
+ warns: boolean;
21
+ /** The formatting the document is expected to hold, when it holds any. */
22
+ holds?: string[];
23
+ }
24
+
25
+ const ROWS: Row[] = [
26
+ { what: "an empty document", html: "", warns: false },
27
+ {
28
+ what: "one paragraph of prose",
29
+ html: "<p>See you at 12:30.</p>",
30
+ warns: false,
31
+ },
32
+ {
33
+ what: "a double Enter between two paragraphs",
34
+ html: "<p>First.</p><p></p><p>Second.</p>",
35
+ warns: false,
36
+ },
37
+ {
38
+ what: "a trailing empty paragraph",
39
+ html: "<p>Thanks.</p><p></p>",
40
+ warns: false,
41
+ },
42
+ {
43
+ what: "a line break inside a paragraph",
44
+ html: "<p>One<br>Two</p>",
45
+ warns: false,
46
+ },
47
+ {
48
+ what: "a bare URL left as characters",
49
+ html: "<p>See https://example.com for the report.</p>",
50
+ warns: false,
51
+ },
52
+ {
53
+ what: "an image, which no registered node can hold",
54
+ html: '<p>Chart: <img src="https://example.com/chart.png" alt="chart"></p>',
55
+ warns: false,
56
+ },
57
+ {
58
+ what: "an underlined word",
59
+ html: "<p>Please <u>read this</u>.</p>",
60
+ warns: true,
61
+ holds: ["underline"],
62
+ },
63
+ {
64
+ what: "a bold word",
65
+ html: "<p>Due <strong>Friday</strong>.</p>",
66
+ warns: true,
67
+ holds: ["bold"],
68
+ },
69
+ {
70
+ what: "a URL Lexical turned into a link",
71
+ html: '<p>See <a href="https://example.com">https://example.com</a>.</p>',
72
+ warns: true,
73
+ holds: ["link"],
74
+ },
75
+ {
76
+ what: "a heading",
77
+ html: "<h2>Quarterly numbers</h2><p>Below.</p>",
78
+ warns: true,
79
+ holds: ["heading"],
80
+ },
81
+ {
82
+ what: "a table",
83
+ html: "<table><tbody><tr><td>EMEA</td><td>412</td></tr></tbody></table>",
84
+ warns: true,
85
+ },
86
+ ];
87
+
88
+ let formattingOf: (html: string) => string[];
89
+
90
+ before(async () => {
91
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
92
+ const dom: JSDOM = new JSDOMCtor(
93
+ "<!doctype html><html><body></body></html>",
94
+ {
95
+ url: "http://localhost/",
96
+ },
97
+ );
98
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
99
+ globalThis.document = dom.window.document;
100
+ globalThis.DOMParser = dom.window.DOMParser;
101
+ globalThis.HTMLElement = dom.window.HTMLElement;
102
+ globalThis.Element = dom.window.Element;
103
+ globalThis.Node = dom.window.Node;
104
+
105
+ const { $getRoot, $insertNodes, createEditor } = await import("lexical");
106
+ const { $adoptHtml, $documentFormatting } = await import(
107
+ "./rich-text-document.js"
108
+ );
109
+ const { RICH_TEXT_NODES } = await import("./rich-text-nodes.js");
110
+
111
+ formattingOf = (html: string) => {
112
+ const editor: LexicalEditor = createEditor({
113
+ namespace: "test",
114
+ nodes: [...RICH_TEXT_NODES],
115
+ onError: (error) => {
116
+ throw error;
117
+ },
118
+ });
119
+ editor.update(
120
+ () => {
121
+ const root = $getRoot();
122
+ root.clear();
123
+ root.select();
124
+ if (html !== "") $insertNodes($adoptHtml(editor, html));
125
+ },
126
+ { discrete: true },
127
+ );
128
+ return editor.read(() => $documentFormatting());
129
+ };
130
+ });
131
+
132
+ describe("what switching to plain text warns about", () => {
133
+ for (const row of ROWS) {
134
+ it(`${row.warns ? "warns about" : "says nothing about"} ${row.what}`, () => {
135
+ const formatting = formattingOf(row.html);
136
+ assert.equal(
137
+ formatting.length > 0,
138
+ row.warns,
139
+ `formatting was ${JSON.stringify(formatting)}`,
140
+ );
141
+ if (row.holds) {
142
+ for (const held of row.holds) assert.ok(formatting.includes(held));
143
+ }
144
+ });
145
+ }
146
+
147
+ it("names what a formatted document holds rather than reducing it to a flag", () => {
148
+ assert.deepEqual(
149
+ formattingOf("<p><strong>Due</strong> <em>Friday</em>.</p>"),
150
+ ["bold", "italic"],
151
+ );
152
+ });
153
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `@lexical/markdown` exports `isTableRowDivider` and no table transformer, so
3
+ * the composer supplies one. Both directions are pinned here: what a recipient
4
+ * reads in the plain part of a rich send, and what comes back when a plain
5
+ * draft is switched to rich.
6
+ *
7
+ * The same transformer set drives the down-conversion of a paste in plain mode,
8
+ * so a table pasted there and a table pasted in rich mode and then switched
9
+ * produce the same characters.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { before, describe, it } from "node:test";
13
+ import type { JSDOM } from "jsdom";
14
+
15
+ const TABLE_HTML = [
16
+ "<table><thead><tr><th>Region</th><th>Total</th></tr></thead>",
17
+ "<tbody><tr><td>EMEA</td><td>412</td></tr>",
18
+ "<tr><td>Americas</td><td>388</td></tr></tbody></table>",
19
+ ].join("");
20
+
21
+ const TABLE_MARKDOWN = [
22
+ "| Region | Total |",
23
+ "| --- | --- |",
24
+ "| EMEA | 412 |",
25
+ "| Americas | 388 |",
26
+ ].join("\n");
27
+
28
+ let htmlToMarkdown: (html: string) => string;
29
+ let markdownToHtml: (markdown: string) => string;
30
+
31
+ before(async () => {
32
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
33
+ const dom: JSDOM = new JSDOMCtor(
34
+ "<!doctype html><html><body></body></html>",
35
+ {
36
+ url: "http://localhost/",
37
+ },
38
+ );
39
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
40
+ globalThis.document = dom.window.document;
41
+ globalThis.DOMParser = dom.window.DOMParser;
42
+ globalThis.HTMLElement = dom.window.HTMLElement;
43
+ globalThis.Element = dom.window.Element;
44
+ globalThis.Node = dom.window.Node;
45
+
46
+ ({ htmlToMarkdown, markdownToHtml } = await import(
47
+ "./rich-text-document.js"
48
+ ));
49
+ });
50
+
51
+ describe("HTML down-converted for the plain surface", () => {
52
+ it("writes a table as pipe rows under a divider", () => {
53
+ assert.equal(htmlToMarkdown(TABLE_HTML).trim(), TABLE_MARKDOWN);
54
+ });
55
+
56
+ it("writes headings, lists and emphasis as Markdown", () => {
57
+ const markdown = htmlToMarkdown(
58
+ [
59
+ "<h2>Release checklist</h2>",
60
+ "<p>Ship <strong>Friday</strong>.</p>",
61
+ "<ol><li>Cut the tag</li><li>Publish the images</li></ol>",
62
+ ].join(""),
63
+ );
64
+
65
+ assert.match(markdown, /## Release checklist/);
66
+ assert.match(markdown, /\*\*Friday\*\*/);
67
+ assert.match(markdown, /1\. Cut the tag/);
68
+ });
69
+
70
+ it("carries no markup through as characters", () => {
71
+ const markdown = htmlToMarkdown(
72
+ '<h2 style="color:#c00">Numbers</h2><script>alert(1)</script>',
73
+ );
74
+
75
+ assert.equal(markdown.includes("<h2"), false);
76
+ assert.equal(markdown.includes("<script"), false);
77
+ assert.equal(markdown.includes("alert(1)"), false);
78
+ assert.equal(markdown.includes("color:#c00"), false);
79
+ });
80
+
81
+ it("keeps a cell's own pipe out of the row it sits in", () => {
82
+ const markdown = htmlToMarkdown(
83
+ "<table><tbody><tr><td>a|b</td><td>c</td></tr></tbody></table>",
84
+ );
85
+ const cells = markdownToHtml(markdown).match(/<t[dh][^>]*>/g) ?? [];
86
+
87
+ assert.match(markdown, /a\\\|b/);
88
+ assert.equal(cells.length, 2);
89
+ });
90
+ });
91
+
92
+ describe("Markdown read back into a document", () => {
93
+ it("renders a pipe table as a table with a header row", () => {
94
+ const html = markdownToHtml(TABLE_MARKDOWN);
95
+
96
+ assert.match(html, /<table/);
97
+ assert.match(html, /<th[^>]*><p>Region/);
98
+ assert.match(html, /EMEA/);
99
+ assert.match(html, /388/);
100
+ });
101
+
102
+ it("renders a heading and emphasis", () => {
103
+ const html = markdownToHtml("## Numbers\n\nDue **Friday**.");
104
+
105
+ assert.match(html, /<h2/);
106
+ assert.match(html, /<strong/);
107
+ });
108
+
109
+ it("returns a table unchanged over a round trip", () => {
110
+ assert.equal(
111
+ htmlToMarkdown(markdownToHtml(TABLE_MARKDOWN)).trim(),
112
+ TABLE_MARKDOWN,
113
+ );
114
+ });
115
+
116
+ it("keeps a divider that heads no table as the characters it is", () => {
117
+ const html = markdownToHtml("| --- | --- |");
118
+
119
+ assert.match(html, /\| --- \| --- \|/);
120
+ assert.equal(html.includes("<table"), false);
121
+ });
122
+
123
+ it("leaves prose that matches no transformer looking the same", () => {
124
+ const prose = "Thanks — that works.\n\nI'll send the deck tomorrow.";
125
+ const html = markdownToHtml(prose);
126
+
127
+ assert.equal((html.match(/<p>/g) ?? []).length, 2);
128
+ assert.match(html, /Thanks/);
129
+ assert.match(html, /send the deck tomorrow/);
130
+ });
131
+ });