@tangle-network/ui 11.33.0 → 11.34.0

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/editor/document-editor-pane.d.ts +7 -1
  3. package/dist/editor/document-editor-pane.js +27 -12
  4. package/dist/editor/markdown-document-editor.js +30 -12
  5. package/dist/editor/source-markdown-editor.js +256 -0
  6. package/dist/editor/source-markdown.js +495 -0
  7. package/dist/files/file-artifact-pane.d.ts +2 -0
  8. package/dist/files/file-artifact-pane.js +1 -0
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +2 -2
  11. package/dist/markdown/markdown-table.js +1 -1
  12. package/dist/run/inline-thinking-item.d.ts +7 -1
  13. package/dist/run/inline-thinking-item.js +6 -5
  14. package/dist/utils/format.d.ts +6 -0
  15. package/dist/utils/format.js +9 -1
  16. package/dist/utils.d.ts +2 -2
  17. package/dist/utils.js +2 -2
  18. package/package.json +2 -2
  19. package/src/editor/document-editor-pane.tsx +54 -16
  20. package/src/editor/markdown-document-editor.tsx +33 -18
  21. package/src/editor/source-markdown-editor.test.tsx +156 -0
  22. package/src/editor/source-markdown-editor.ts +296 -0
  23. package/src/editor/source-markdown.fixtures.ts +90 -0
  24. package/src/editor/source-markdown.test.ts +235 -0
  25. package/src/editor/source-markdown.ts +627 -0
  26. package/src/files/file-artifact-pane.test.tsx +34 -1
  27. package/src/files/file-artifact-pane.tsx +3 -0
  28. package/src/markdown/markdown-table.test.tsx +9 -0
  29. package/src/markdown/markdown-table.tsx +5 -1
  30. package/src/run/inline-thinking-item.test.tsx +29 -0
  31. package/src/run/inline-thinking-item.tsx +11 -4
  32. package/src/utils/format.test.ts +16 -1
  33. package/src/utils/format.ts +17 -0
  34. package/src/utils/index.ts +1 -1
@@ -0,0 +1,296 @@
1
+ /**
2
+ * The tiptap half of source-preserving Markdown editing (see
3
+ * `source-markdown.ts`): a read-only node for blocks the editor cannot
4
+ * represent, a block-id attribute on every top-level node, and the session
5
+ * that maps an editor document back onto the file's original bytes.
6
+ *
7
+ * tiptap arrives as an argument, so this module reaches it through type-only
8
+ * imports, which a bundler erases (see `editor-peers.ts`).
9
+ */
10
+
11
+ import type * as TiptapReact from "@tiptap/react";
12
+ import { marked, type Token, type Tokens } from "marked";
13
+ import {
14
+ asReadOnlyDocument,
15
+ BLOCK_ID_ATTRIBUTE,
16
+ blockHtml,
17
+ blockMarkdown,
18
+ createBlockMerger,
19
+ parseSourceMarkdown,
20
+ serializeSourceMarkdown,
21
+ SOURCE_KIND_ATTRIBUTE,
22
+ SOURCE_RAW_ATTRIBUTE,
23
+ withReadOnlyBlocks,
24
+ type SourceBlockKind,
25
+ type SourceMarkdown,
26
+ type SourceNode,
27
+ } from "./source-markdown";
28
+
29
+ type Core = typeof TiptapReact;
30
+ type Editor = TiptapReact.Editor;
31
+ type PMNode = Editor["state"]["doc"];
32
+ type Schema = Editor["schema"];
33
+ type DOMOutputSpec = unknown[] | string;
34
+
35
+ /** Name of the read-only node that holds a block's raw Markdown. */
36
+ export const SOURCE_NODE = "markdownSource";
37
+
38
+ /** Top-level node types that can carry a block id. */
39
+ const BLOCK_TYPES = [
40
+ "paragraph",
41
+ "heading",
42
+ "bulletList",
43
+ "orderedList",
44
+ "blockquote",
45
+ "codeBlock",
46
+ "horizontalRule",
47
+ SOURCE_NODE,
48
+ ];
49
+
50
+ const CAPTIONS: Record<SourceBlockKind, string> = {
51
+ table: "Table",
52
+ html: "HTML",
53
+ image: "Image",
54
+ checklist: "Checklist",
55
+ definition: "Link definition",
56
+ markdown: "Markdown",
57
+ paragraph: "Markdown",
58
+ heading: "Markdown",
59
+ code: "Markdown",
60
+ blockquote: "Markdown",
61
+ list: "Markdown",
62
+ hr: "Markdown",
63
+ };
64
+
65
+ function inlineText(tokens: Token[] | undefined): string {
66
+ return (tokens ?? [])
67
+ .map((token) => {
68
+ switch (token.type) {
69
+ case "image":
70
+ return (token as Tokens.Image).text;
71
+ case "br":
72
+ return " ";
73
+ case "html":
74
+ return "";
75
+ default: {
76
+ const children = (token as { tokens?: Token[] }).tokens;
77
+ return children ? inlineText(children) : (token as { text?: string }).text ?? "";
78
+ }
79
+ }
80
+ })
81
+ .join("");
82
+ }
83
+
84
+ function tablePreview(raw: string): DOMOutputSpec | null {
85
+ const table = marked.lexer(raw, { gfm: true }).find((token) => token.type === "table") as
86
+ | Tokens.Table
87
+ | undefined;
88
+ if (!table) return null;
89
+ const cell = (tag: "th" | "td", value: Tokens.TableCell) => [tag, {}, inlineText(value.tokens)];
90
+ return [
91
+ "table",
92
+ {},
93
+ ["thead", {}, ["tr", {}, ...table.header.map((value) => cell("th", value))]],
94
+ ["tbody", {}, ...table.rows.map((row) => ["tr", {}, ...row.map((value) => cell("td", value))])],
95
+ ];
96
+ }
97
+
98
+ function sourcePreview(raw: string, kind: SourceBlockKind): DOMOutputSpec[] {
99
+ const caption: DOMOutputSpec = [
100
+ "p",
101
+ { class: "not-prose m-0 mb-2 text-sm text-muted-foreground" },
102
+ `${CAPTIONS[kind] ?? "Markdown"} kept exactly as written. Edit it in the Markdown source.`,
103
+ ];
104
+ const table = kind === "table" ? tablePreview(raw) : null;
105
+ if (table) return [caption, ["div", { class: "overflow-x-auto" }, table]];
106
+ return [caption, ["pre", { class: "m-0 whitespace-pre-wrap" }, ["code", {}, raw]]];
107
+ }
108
+
109
+ /** The read-only source node and the block-id attribute, built from the loaded tiptap. */
110
+ export function createSourceMarkdownExtensions(core: Core) {
111
+ const SourceBlock = core.Node.create({
112
+ name: SOURCE_NODE,
113
+ group: "block",
114
+ atom: true,
115
+ selectable: true,
116
+ draggable: false,
117
+ addAttributes() {
118
+ return {
119
+ raw: {
120
+ default: "",
121
+ parseHTML: (element: HTMLElement) => element.getAttribute(SOURCE_RAW_ATTRIBUTE) ?? "",
122
+ renderHTML: (attributes: Record<string, unknown>) => ({ [SOURCE_RAW_ATTRIBUTE]: attributes.raw }),
123
+ },
124
+ kind: {
125
+ default: "markdown",
126
+ parseHTML: (element: HTMLElement) => element.getAttribute(SOURCE_KIND_ATTRIBUTE) ?? "markdown",
127
+ renderHTML: (attributes: Record<string, unknown>) => ({ [SOURCE_KIND_ATTRIBUTE]: attributes.kind }),
128
+ },
129
+ };
130
+ },
131
+ parseHTML() {
132
+ return [{ tag: `div[${SOURCE_RAW_ATTRIBUTE}]` }];
133
+ },
134
+ renderHTML({ node, HTMLAttributes }) {
135
+ return [
136
+ "div",
137
+ core.mergeAttributes(HTMLAttributes, {
138
+ class: "my-3 rounded-lg border border-dashed border-border bg-muted/40 p-3",
139
+ }),
140
+ ...sourcePreview(String(node.attrs.raw ?? ""), node.attrs.kind as SourceBlockKind),
141
+ ] as never;
142
+ },
143
+ });
144
+
145
+ const BlockIdentity = core.Extension.create({
146
+ name: "markdownSourceBlockId",
147
+ addGlobalAttributes() {
148
+ return [
149
+ {
150
+ types: BLOCK_TYPES,
151
+ attributes: {
152
+ mdBlock: {
153
+ default: null,
154
+ // A split's new half is new text, not a copy of the source block.
155
+ keepOnSplit: false,
156
+ parseHTML: (element: HTMLElement) => element.getAttribute(BLOCK_ID_ATTRIBUTE),
157
+ renderHTML: (attributes: Record<string, unknown>) =>
158
+ attributes.mdBlock ? { [BLOCK_ID_ATTRIBUTE]: attributes.mdBlock } : {},
159
+ },
160
+ },
161
+ },
162
+ ];
163
+ },
164
+ });
165
+
166
+ return [SourceBlock, BlockIdentity];
167
+ }
168
+
169
+ function blockIdOf(node: PMNode): string | null {
170
+ const id = (node.attrs as { mdBlock?: unknown }).mdBlock;
171
+ return typeof id === "string" && id ? id : null;
172
+ }
173
+
174
+ function isEmptyParagraph(node: PMNode) {
175
+ return node.type.name === "paragraph" && node.content.size === 0;
176
+ }
177
+
178
+ function withoutBlockIds(value: unknown): unknown {
179
+ if (Array.isArray(value)) return value.map(withoutBlockIds);
180
+ if (!value || typeof value !== "object") return value;
181
+ const out: Record<string, unknown> = {};
182
+ for (const [key, entry] of Object.entries(value)) {
183
+ if (key === "mdBlock") continue;
184
+ out[key] = withoutBlockIds(entry);
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /**
190
+ * One open file in the rich editor: its source blocks, the nodes they were
191
+ * first parsed into, and the Markdown written back for the current document.
192
+ */
193
+ export class SourceMarkdownSession {
194
+ source: SourceMarkdown;
195
+ private readonly core: Core;
196
+ private readonly markdown: string;
197
+ private readonly initial = new Map<string, PMNode>();
198
+ private readonly mergers = new Map<string, (edited: string) => string | null>();
199
+
200
+ constructor(core: Core, markdown: string) {
201
+ this.core = core;
202
+ this.markdown = markdown;
203
+ this.source = parseSourceMarkdown(markdown);
204
+ }
205
+
206
+ get html() {
207
+ return this.source.html;
208
+ }
209
+
210
+ /**
211
+ * Records the nodes the editor parsed. A block the editor document does not
212
+ * hold as exactly one top-level node (dropped, split, or merged by the
213
+ * schema) is shown read-only instead, so no source byte can be lost.
214
+ */
215
+ attach(editor: Editor) {
216
+ for (let attempt = 0; attempt < 3; attempt++) {
217
+ const unmapped = this.capture(editor.state.doc);
218
+ if (unmapped.size === 0) return;
219
+ this.source = attempt < 2 ? withReadOnlyBlocks(this.source, unmapped) : asReadOnlyDocument(this.markdown);
220
+ editor.commands.setContent(this.source.html, { emitUpdate: false });
221
+ }
222
+ this.capture(editor.state.doc);
223
+ }
224
+
225
+ private capture(doc: PMNode): Set<string> {
226
+ this.initial.clear();
227
+ this.mergers.clear();
228
+ const unmapped = new Set<string>();
229
+ let previous: string | null = null;
230
+ doc.forEach((node, _offset, index) => {
231
+ const id = blockIdOf(node);
232
+ if (id && this.source.byId.has(id)) {
233
+ if (this.initial.has(id)) unmapped.add(id);
234
+ else this.initial.set(id, node);
235
+ previous = id;
236
+ return;
237
+ }
238
+ if (index === doc.childCount - 1 && isEmptyParagraph(node)) return;
239
+ // A node with no id came out of the block before it (or the first block).
240
+ const owner = previous ?? this.source.blocks[0]?.id;
241
+ if (owner) unmapped.add(owner);
242
+ });
243
+ for (const block of this.source.blocks) {
244
+ if (!this.initial.has(block.id)) unmapped.add(block.id);
245
+ }
246
+ return unmapped;
247
+ }
248
+
249
+ private toMarkdown(node: PMNode, raw: string) {
250
+ const fragment = node.type.schema.topNodeType.create(null, node).content;
251
+ return blockMarkdown(this.core.getHTMLFromFragment(fragment, node.type.schema), raw);
252
+ }
253
+
254
+ private reparses(markdown: string, node: PMNode, schema: Schema) {
255
+ const html = blockHtml(markdown, this.source.links);
256
+ if (html === null) return false;
257
+ const parsed = this.core.createDocument(html, schema);
258
+ const nodes: PMNode[] = [];
259
+ parsed.forEach((child) => {
260
+ if (!isEmptyParagraph(child)) nodes.push(child);
261
+ });
262
+ return (
263
+ nodes.length === 1 &&
264
+ JSON.stringify(withoutBlockIds(nodes[0].toJSON())) === JSON.stringify(withoutBlockIds(node.toJSON()))
265
+ );
266
+ }
267
+
268
+ private mergerFor(id: string, initial: PMNode, raw: string) {
269
+ let merge = this.mergers.get(id);
270
+ if (!merge) {
271
+ merge = createBlockMerger(raw, this.toMarkdown(initial, raw));
272
+ this.mergers.set(id, merge);
273
+ }
274
+ return merge;
275
+ }
276
+
277
+ /** The file's Markdown for `doc`: original bytes wherever the reader changed nothing. */
278
+ serialize(doc: PMNode): string {
279
+ const nodes: SourceNode[] = [];
280
+ doc.forEach((node) => {
281
+ const id = blockIdOf(node);
282
+ const block = id ? this.source.byId.get(id) : undefined;
283
+ const initial = id ? this.initial.get(id) : undefined;
284
+ const raw = node.type.name === SOURCE_NODE ? String(node.attrs.raw ?? "") : null;
285
+ nodes.push({
286
+ block: initial ? block : undefined,
287
+ unchanged: Boolean(initial?.eq(node)),
288
+ sameType: initial?.type === node.type,
289
+ markdown: () => raw ?? this.toMarkdown(node, block?.raw ?? ""),
290
+ merge: (edited) => (initial && block ? this.mergerFor(block.id, initial, block.raw)(edited) : null),
291
+ reparses: (markdown) => this.reparses(markdown, node, node.type.schema),
292
+ });
293
+ });
294
+ return serializeSourceMarkdown(this.source, nodes);
295
+ }
296
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Markdown fixtures for the source-preserving editor tests.
3
+ */
4
+
5
+ /**
6
+ * One of every block and inline construct, in the less common spellings too.
7
+ * Trailing spaces are written as \u0020 so an editor cannot strip them.
8
+ */
9
+ export const KITCHEN_SINK = `
10
+
11
+ # Title with \`code\` and **bold**
12
+
13
+ Setext heading
14
+ ==============
15
+
16
+ Paragraph with *star emphasis*, _underscore emphasis_, __double under__, snake_case_name, a\\_b escaped,
17
+ a hard break here\u0020\u0020
18
+ and a [link](https://example.com "Title"), a [ref link][ref], <https://auto.link>, and ~~strike~~.
19
+
20
+ * star bullet one
21
+ * star bullet two
22
+ * nested four-space
23
+
24
+ + plus list
25
+
26
+ 1) paren ordered
27
+ 2) second
28
+
29
+ 3. starts at three
30
+ 4. four
31
+
32
+ - [ ] task open
33
+ - [x] task done
34
+
35
+ > quote line one
36
+ > > nested quote
37
+
38
+ \`\`\`ts
39
+ const x = 1 // fenced
40
+ \`\`\`
41
+
42
+ ~~~
43
+ tilde fence
44
+ ~~~
45
+
46
+ indented code
47
+
48
+ ***
49
+
50
+ ---
51
+ ___
52
+
53
+ | Left | Center | Right |
54
+ |:-----|:------:|------:|
55
+ | a | **b** | [c](https://c.example) |
56
+
57
+ <div align="center">raw html block</div>
58
+
59
+ ![image alt](images/pic.png "pic title")
60
+
61
+ Text with inline <sup>html</sup> and an emoji 🚀 and trailing spaces\u0020\u0020\u0020
62
+
63
+ [ref]: https://ref.example "Ref Title"
64
+
65
+ Line with trailing backslash\\
66
+ next line
67
+ `;
68
+
69
+
70
+ /** Shaped like an agent-written competitor call sheet: headings, prose and GFM tables. */
71
+ export const CALL_SHEET = `# Plausible Analytics: competitor call sheet
72
+
73
+ **Updated:** October 11, 2026 · **Use:** establish what the buyer is comparing before positioning Plausible.
74
+
75
+ ## Three deal paths
76
+
77
+ | Competitor | Why a buyer may choose it | Call response |
78
+ |---|---|---|
79
+ | Google Analytics 4 | Standard GA4 is free of charge. ([GA4](https://example.com/ga4)) | **Acknowledge:** the no-cost offer is real. |
80
+ | Fathom | Closest paid, cookie-free alternative. | Ask which reports they rely on today. |
81
+ | Simple Analytics | Stricter no-identifier model. | Show the dashboard and ask what is missing. |
82
+
83
+ ## Qualifying questions
84
+
85
+ 1. Which reports do you open every week?
86
+ 2. Who signs off on analytics spend?
87
+
88
+ * Keep the call to 20 minutes
89
+ * Send the comparison after the call
90
+ `;
@@ -0,0 +1,235 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ blockHtml,
4
+ blockMarkdown,
5
+ diffText,
6
+ mergeBlockEdit,
7
+ parseSourceMarkdown,
8
+ serializeSourceMarkdown,
9
+ type SourceBlock,
10
+ type SourceMarkdown,
11
+ type SourceNode,
12
+ } from "./source-markdown";
13
+ import { KITCHEN_SINK } from "./source-markdown.fixtures";
14
+
15
+ /** The serializer's view of a document nobody edited. */
16
+ function untouched(source: SourceMarkdown): SourceNode[] {
17
+ return source.blocks.map((block) => ({
18
+ block,
19
+ unchanged: true,
20
+ sameType: true,
21
+ markdown: () => block.raw,
22
+ merge: () => block.raw,
23
+ reparses: () => true,
24
+ }));
25
+ }
26
+
27
+ /** A node whose Markdown changed to `edited`, merged the way the editor merges it. */
28
+ function editedNode(block: SourceBlock, canonical: string, edited: string): SourceNode {
29
+ return {
30
+ block,
31
+ unchanged: false,
32
+ sameType: true,
33
+ markdown: () => edited,
34
+ merge: (text) => mergeBlockEdit(block.raw, canonical, text),
35
+ reparses: () => true,
36
+ };
37
+ }
38
+
39
+ function addedNode(markdown: string): SourceNode {
40
+ return {
41
+ unchanged: false,
42
+ sameType: false,
43
+ markdown: () => markdown,
44
+ merge: () => null,
45
+ reparses: () => true,
46
+ };
47
+ }
48
+
49
+ describe("parseSourceMarkdown", () => {
50
+ it("splits a file into blocks whose bytes reassemble it exactly", () => {
51
+ const source = parseSourceMarkdown(KITCHEN_SINK);
52
+ const rebuilt = source.prefix + source.blocks.map((block) => block.raw + block.gap).join("");
53
+ expect(rebuilt).toBe(KITCHEN_SINK);
54
+ expect(source.prefix).toBe("\n\n");
55
+ });
56
+
57
+ it("keeps blocks the editor cannot represent read-only, by what they are", () => {
58
+ const kinds = parseSourceMarkdown(KITCHEN_SINK)
59
+ .blocks.filter((block) => !block.editable)
60
+ .map((block) => block.kind);
61
+ expect(kinds).toEqual(["checklist", "table", "html", "image", "html", "definition"]);
62
+ });
63
+
64
+ it("gives the editor a source node for a table, carrying its raw text", () => {
65
+ const table = "| A | B |\n|---|---|\n| 1 | 2 |";
66
+ const source = parseSourceMarkdown(`Intro\n\n${table}\n`);
67
+ expect(source.html).toContain('data-md-kind="table"');
68
+ expect(source.html).toContain('data-md-raw="| A | B |\n|---|---|\n| 1 | 2 |"');
69
+ expect(source.html).toMatch(/^<p data-md-block="m[0-9a-z]+:0">Intro<\/p>/);
70
+ });
71
+
72
+ it("keeps CRLF line endings and a missing final newline", () => {
73
+ for (const file of ["one\r\n\r\n- a\r\n- b\r\n", "no final newline", "\ttab start\n\n-\ttab bullet"]) {
74
+ const source = parseSourceMarkdown(file);
75
+ expect(serializeSourceMarkdown(source, untouched(source))).toBe(file);
76
+ }
77
+ });
78
+
79
+ it("serializes an untouched file byte for byte", () => {
80
+ const source = parseSourceMarkdown(KITCHEN_SINK);
81
+ expect(serializeSourceMarkdown(source, untouched(source))).toBe(KITCHEN_SINK);
82
+ });
83
+
84
+ it("resolves reference links defined elsewhere when it re-reads one block", () => {
85
+ const source = parseSourceMarkdown("See [docs][d].\n\n[d]: https://docs.example\n");
86
+ expect(blockHtml("See [the docs][d].", source.links)).toContain('href="https://docs.example"');
87
+ expect(blockHtml("| a |\n|---|\n| 1 |")).toBeNull();
88
+ });
89
+ });
90
+
91
+ describe("mergeBlockEdit", () => {
92
+ it("keeps escapes the re-serialization would add, outside the edit", () => {
93
+ const raw = "Rename snake_case_name and *keep* it";
94
+ const canonical = "Rename snake\\_case\\_name and *keep* it";
95
+ expect(mergeBlockEdit(raw, canonical, "Rename snake\\_case\\_name and *keep* it now")).toBe(
96
+ "Rename snake_case_name and *keep* it now",
97
+ );
98
+ });
99
+
100
+ it("keeps the source's list markers and indentation around an edited item", () => {
101
+ const raw = "* star bullet one\n* star bullet two\n * nested four-space";
102
+ const canonical = "- star bullet one\n- star bullet two\n - nested four-space";
103
+ const edited = "- star bullet uno\n- star bullet two\n - nested four-space";
104
+ expect(mergeBlockEdit(raw, canonical, edited)).toBe(
105
+ "* star bullet uno\n* star bullet two\n * nested four-space",
106
+ );
107
+ });
108
+
109
+ it("keeps a setext heading's underline and underscore emphasis", () => {
110
+ expect(mergeBlockEdit("Setext heading\n==============", "# Setext heading", "# Setext heading two")).toBe(
111
+ "Setext heading two\n==============",
112
+ );
113
+ expect(mergeBlockEdit("an _emphasised_ word", "an *emphasised* word", "an *emphasised* word, edited")).toBe(
114
+ "an _emphasised_ word, edited",
115
+ );
116
+ });
117
+
118
+ it("keeps a reference link and a backslash hard break the canonical form spells differently", () => {
119
+ expect(
120
+ mergeBlockEdit(
121
+ "A [ref link][ref] here",
122
+ 'A [ref link](https://ref.example "Ref Title") here',
123
+ 'A [ref link](https://ref.example "Ref Title") here and there',
124
+ ),
125
+ ).toBe("A [ref link][ref] here and there");
126
+ expect(mergeBlockEdit("one\\\ntwo", "one \ntwo", "one! \ntwo")).toBe("one!\\\ntwo");
127
+ });
128
+
129
+ it("deletes the source's own spelling of a deleted span", () => {
130
+ expect(mergeBlockEdit("keep snake_case drop", "keep snake\\_case drop", "keep drop")).toBe("keep drop");
131
+ });
132
+
133
+ it("returns the original bytes when the edit was undone", () => {
134
+ expect(mergeBlockEdit("* a", "- a", "- a")).toBe("* a");
135
+ });
136
+ });
137
+
138
+ describe("diffText", () => {
139
+ // Seeded, so a failure reproduces.
140
+ function random(seed: number) {
141
+ let state = seed;
142
+ return () => {
143
+ state = (state * 1103515245 + 12345) % 2147483648;
144
+ return state / 2147483648;
145
+ };
146
+ }
147
+
148
+ it("turns the first text into the second for random edits", () => {
149
+ const next = random(42);
150
+ const alphabet = "ab *_-\n#[]()`\\";
151
+ for (let round = 0; round < 300; round++) {
152
+ const length = Math.floor(next() * 60);
153
+ let a = "";
154
+ for (let i = 0; i < length; i++) a += alphabet[Math.floor(next() * alphabet.length)];
155
+ let b = a;
156
+ const edits = 1 + Math.floor(next() * 4);
157
+ for (let e = 0; e < edits; e++) {
158
+ const at = Math.floor(next() * (b.length + 1));
159
+ const cut = Math.floor(next() * 4);
160
+ const insert = alphabet[Math.floor(next() * alphabet.length)].repeat(Math.floor(next() * 3));
161
+ b = b.slice(0, at) + insert + b.slice(at + cut);
162
+ }
163
+ const ops = diffText(a, b);
164
+ expect(ops).not.toBeNull();
165
+ expect(ops!.filter(([op]) => op !== 1).map(([, text]) => text).join("")).toBe(a);
166
+ expect(ops!.filter(([op]) => op !== -1).map(([, text]) => text).join("")).toBe(b);
167
+ }
168
+ });
169
+
170
+ it("gives up past its cost bound rather than allocating without limit", () => {
171
+ expect(diffText("a".repeat(400), "b".repeat(400), 100)).toBeNull();
172
+ });
173
+ });
174
+
175
+ describe("serializeSourceMarkdown", () => {
176
+ const file = "# Title\n\nFirst paragraph.\n\n* one\n* two\n\n| A |\n|---|\n| 1 |\n";
177
+
178
+ it("changes only the edited span of an edited block", () => {
179
+ const source = parseSourceMarkdown(file);
180
+ const nodes = untouched(source);
181
+ nodes[2] = editedNode(source.blocks[2], "- one\n- two", "- one\n- two\n- three");
182
+ expect(serializeSourceMarkdown(source, nodes)).toBe(
183
+ "# Title\n\nFirst paragraph.\n\n* one\n* two\n- three\n\n| A |\n|---|\n| 1 |\n",
184
+ );
185
+ });
186
+
187
+ it("separates an added block with a blank line and keeps the file's trailer", () => {
188
+ const source = parseSourceMarkdown(file);
189
+ const nodes = untouched(source);
190
+ nodes.splice(2, 0, addedNode("A new paragraph."));
191
+ nodes.push(addedNode("Closing line."));
192
+ expect(serializeSourceMarkdown(source, nodes)).toBe(
193
+ "# Title\n\nFirst paragraph.\n\nA new paragraph.\n\n* one\n* two\n\n| A |\n|---|\n| 1 |\n\nClosing line.\n",
194
+ );
195
+ });
196
+
197
+ it("drops a deleted block and its separator only", () => {
198
+ const source = parseSourceMarkdown(file);
199
+ const nodes = untouched(source).filter((_, i) => i !== 1);
200
+ expect(serializeSourceMarkdown(source, nodes)).toBe("# Title\n\n* one\n* two\n\n| A |\n|---|\n| 1 |\n");
201
+ });
202
+
203
+ it("skips an empty added paragraph, such as the editor's trailing one", () => {
204
+ const source = parseSourceMarkdown(file);
205
+ expect(serializeSourceMarkdown(source, [...untouched(source), addedNode("")])).toBe(file);
206
+ });
207
+
208
+ it("writes added text with the file's CRLF line endings", () => {
209
+ const source = parseSourceMarkdown("one\r\n");
210
+ expect(serializeSourceMarkdown(source, [...untouched(source), addedNode("two\nlines")])).toBe(
211
+ "one\r\n\r\ntwo\r\nlines\r\n",
212
+ );
213
+ });
214
+
215
+ it("falls back to the edited block when the merge does not read back as the edit", () => {
216
+ const source = parseSourceMarkdown("* one\n* two\n");
217
+ const node = editedNode(source.blocks[0], "- one\n- two", "- one\n- two\n- three");
218
+ node.reparses = () => false;
219
+ expect(serializeSourceMarkdown(source, [node])).toBe("- one\n- two\n- three\n");
220
+ });
221
+ });
222
+
223
+ describe("blockMarkdown", () => {
224
+ it("writes lists with one space after the marker, in the source's marker style", () => {
225
+ const html = "<ul><li><p>one</p></li><li><p>two</p></li></ul>";
226
+ expect(blockMarkdown(html)).toBe("- one\n- two");
227
+ expect(blockMarkdown(html, "* x")).toBe("* one\n* two");
228
+ expect(blockMarkdown('<ol start="3"><li><p>a</p></li><li><p>b</p></li></ol>', "3) a")).toBe("3) a\n4) b");
229
+ });
230
+
231
+ it("writes strikethrough and rules as GFM", () => {
232
+ expect(blockMarkdown("<p><s>gone</s></p>")).toBe("~~gone~~");
233
+ expect(blockMarkdown("<hr>")).toBe("---");
234
+ });
235
+ });