diff --git a/test/filters.test.ts b/test/filters.test.ts
new file mode 100644
index 0000000..020dc9d
--- /dev/null
+++ b/test/filters.test.ts
@@ -0,0 +1,234 @@
+import { describe, expect, it } from 'vitest';
+import {
+ filterComment,
+ filterGroup,
+ filterPage,
+ filterSearchResult,
+ filterSpace,
+ filterWorkspace,
+} from '../packages/docmost-client/src/lib/filters.js';
+
+describe('filterPage', () => {
+ const basePage = {
+ id: 'pg1',
+ slugId: 'slug-1',
+ title: 'Title',
+ parentPageId: 'parent-1',
+ spaceId: 'space-1',
+ isLocked: false,
+ createdAt: '2024-01-01',
+ updatedAt: '2024-01-02',
+ deletedAt: null,
+ // Extra fields that must be dropped by the filter.
+ secret: 'should-not-leak',
+ };
+
+ it('omits the content key when no content arg is given', () => {
+ const result = filterPage(basePage);
+ expect(result).not.toHaveProperty('content');
+ expect(result).not.toHaveProperty('secret');
+ expect(result.id).toBe('pg1');
+ });
+
+ it('includes an empty-string content (truthiness trap: empty string is kept)', () => {
+ const result = filterPage(basePage, '');
+ expect(result).toHaveProperty('content');
+ expect(result.content).toBe('');
+ });
+
+ it('includes a non-empty content string', () => {
+ const result = filterPage(basePage, '# Markdown');
+ expect(result.content).toBe('# Markdown');
+ });
+
+ it('omits content when it is not a string', () => {
+ // A non-string (e.g. an object passed by mistake) must be dropped.
+ const result = filterPage(basePage, { junk: true } as unknown as string);
+ expect(result).not.toHaveProperty('content');
+ });
+
+ it('omits subpages when undefined', () => {
+ const result = filterPage(basePage);
+ expect(result).not.toHaveProperty('subpages');
+ });
+
+ it('omits subpages when empty array', () => {
+ const result = filterPage(basePage, undefined, []);
+ expect(result).not.toHaveProperty('subpages');
+ });
+
+ it('maps non-empty subpages to { id, title } only', () => {
+ const result = filterPage(basePage, undefined, [
+ { id: 's1', title: 'Sub 1', extra: 'drop-me' },
+ { id: 's2', title: 'Sub 2' },
+ ]);
+ expect(result.subpages).toEqual([
+ { id: 's1', title: 'Sub 1' },
+ { id: 's2', title: 'Sub 2' },
+ ]);
+ });
+});
+
+describe('filterComment', () => {
+ const baseComment = {
+ id: 'c1',
+ pageId: 'pg1',
+ content: 'original content',
+ creatorId: 'u1',
+ createdAt: '2024-01-01',
+ };
+
+ it('overrides comment.content with markdownContent when provided', () => {
+ const result = filterComment(baseComment, 'markdown version');
+ expect(result.content).toBe('markdown version');
+ });
+
+ it('falls back to comment.content when markdownContent is undefined', () => {
+ const result = filterComment(baseComment, undefined);
+ expect(result.content).toBe('original content');
+ });
+
+ it('keeps an empty-string markdownContent (uses ?? not ||)', () => {
+ // `??` only falls back on null/undefined, so "" must be preserved.
+ const result = filterComment(baseComment, '');
+ expect(result.content).toBe('');
+ });
+
+ it('applies defaults for selection/type/parentCommentId/editedAt/resolvedAt/resolvedById', () => {
+ const result = filterComment(baseComment);
+ expect(result.selection).toBeNull();
+ expect(result.type).toBe('page');
+ expect(result.parentCommentId).toBeNull();
+ expect(result.editedAt).toBeNull();
+ expect(result.resolvedAt).toBeNull();
+ expect(result.resolvedById).toBeNull();
+ });
+
+ it('passes through provided optional values', () => {
+ const result = filterComment({
+ ...baseComment,
+ selection: 'some text',
+ type: 'inline',
+ parentCommentId: 'c0',
+ editedAt: '2024-02-01',
+ resolvedAt: '2024-03-01',
+ resolvedById: 'u9',
+ });
+ expect(result.selection).toBe('some text');
+ expect(result.type).toBe('inline');
+ expect(result.parentCommentId).toBe('c0');
+ expect(result.editedAt).toBe('2024-02-01');
+ expect(result.resolvedAt).toBe('2024-03-01');
+ expect(result.resolvedById).toBe('u9');
+ });
+
+ it('returns null for creatorName when creator is absent', () => {
+ const result = filterComment(baseComment);
+ expect(result.creatorName).toBeNull();
+ });
+
+ it('reads the nested creator?.name when present', () => {
+ const result = filterComment({
+ ...baseComment,
+ creator: { name: 'Alice' },
+ });
+ expect(result.creatorName).toBe('Alice');
+ });
+});
+
+describe('filterSearchResult', () => {
+ const baseResult = {
+ id: 'r1',
+ title: 'Result',
+ parentPageId: 'p0',
+ createdAt: '2024-01-01',
+ updatedAt: '2024-01-02',
+ rank: 0.9,
+ highlight: 'Result',
+ };
+
+ it('reads nested space?.id and space?.name when space is present', () => {
+ const result = filterSearchResult({
+ ...baseResult,
+ space: { id: 'sp1', name: 'Space One' },
+ });
+ expect(result.spaceId).toBe('sp1');
+ expect(result.spaceName).toBe('Space One');
+ });
+
+ it('returns undefined for space fields when space is absent (no throw)', () => {
+ const result = filterSearchResult(baseResult);
+ expect(result.spaceId).toBeUndefined();
+ expect(result.spaceName).toBeUndefined();
+ });
+});
+
+describe('flat pluckers (no branching)', () => {
+ it('filterWorkspace plucks the expected shape', () => {
+ const result = filterWorkspace({
+ id: 'w1',
+ name: 'WS',
+ description: 'desc',
+ defaultSpaceId: 'sp1',
+ createdAt: '2024-01-01',
+ updatedAt: '2024-01-02',
+ deletedAt: null,
+ extra: 'drop',
+ });
+ expect(result).toEqual({
+ id: 'w1',
+ name: 'WS',
+ description: 'desc',
+ defaultSpaceId: 'sp1',
+ createdAt: '2024-01-01',
+ updatedAt: '2024-01-02',
+ deletedAt: null,
+ });
+ });
+
+ it('filterSpace plucks the expected shape', () => {
+ const result = filterSpace({
+ id: 'sp1',
+ name: 'Space',
+ description: 'desc',
+ slug: 'space',
+ visibility: 'open',
+ createdAt: '2024-01-01',
+ updatedAt: '2024-01-02',
+ deletedAt: null,
+ extra: 'drop',
+ });
+ expect(result).toEqual({
+ id: 'sp1',
+ name: 'Space',
+ description: 'desc',
+ slug: 'space',
+ visibility: 'open',
+ createdAt: '2024-01-01',
+ updatedAt: '2024-01-02',
+ deletedAt: null,
+ });
+ });
+
+ it('filterGroup plucks the expected shape', () => {
+ const result = filterGroup({
+ id: 'g1',
+ name: 'Group',
+ description: 'desc',
+ workspaceId: 'w1',
+ createdAt: '2024-01-01',
+ updatedAt: '2024-01-02',
+ deletedAt: null,
+ extra: 'drop',
+ });
+ expect(result).toEqual({
+ id: 'g1',
+ name: 'Group',
+ description: 'desc',
+ workspaceId: 'w1',
+ createdAt: '2024-01-01',
+ updatedAt: '2024-01-02',
+ deletedAt: null,
+ });
+ });
+});
diff --git a/test/json-edit.test.ts b/test/json-edit.test.ts
new file mode 100644
index 0000000..95a1383
--- /dev/null
+++ b/test/json-edit.test.ts
@@ -0,0 +1,288 @@
+import { describe, expect, it } from 'vitest';
+import { applyTextEdits } from '../packages/docmost-client/src/lib/json-edit.js';
+
+// Helper: build a ProseMirror text node.
+function text(value: string, extra: Record = {}) {
+ return { type: 'text', text: value, ...extra };
+}
+
+// Helper: a simple doc with a single paragraph containing one text node.
+function singleParagraph(value: string) {
+ return {
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [text(value)] }],
+ };
+}
+
+describe('applyTextEdits', () => {
+ describe('single match', () => {
+ it('replaces an exact single match and reports one replacement', () => {
+ const doc = singleParagraph('hello world');
+ const { doc: result, results } = applyTextEdits(doc, [
+ { find: 'world', replace: 'there' },
+ ]);
+
+ expect(result.content[0].content[0].text).toBe('hello there');
+ expect(results).toEqual([{ find: 'world', replacements: 1 }]);
+ });
+
+ it('preserves node id/marks around the edited text', () => {
+ const doc = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ attrs: { id: 'p1' },
+ content: [
+ text('old value', { marks: [{ type: 'bold' }], custom: 42 }),
+ ],
+ },
+ ],
+ };
+ const { doc: result } = applyTextEdits(doc, [
+ { find: 'old', replace: 'new' },
+ ]);
+
+ const node = result.content[0].content[0];
+ expect(node.text).toBe('new value');
+ expect(node.marks).toEqual([{ type: 'bold' }]);
+ expect(node.custom).toBe(42);
+ expect(result.content[0].attrs).toEqual({ id: 'p1' });
+ });
+ });
+
+ describe('replaceAll', () => {
+ it('replaces every occurrence inside a single node when replaceAll is set', () => {
+ const doc = singleParagraph('a a a');
+ const { doc: result, results } = applyTextEdits(doc, [
+ { find: 'a', replace: 'b', replaceAll: true },
+ ]);
+
+ expect(result.content[0].content[0].text).toBe('b b b');
+ expect(results).toEqual([{ find: 'a', replacements: 3 }]);
+ });
+
+ it('replaces occurrences across multiple separate text nodes', () => {
+ const doc = {
+ type: 'doc',
+ content: [
+ { type: 'paragraph', content: [text('foo and foo')] },
+ { type: 'paragraph', content: [text('also foo')] },
+ ],
+ };
+ const { doc: result, results } = applyTextEdits(doc, [
+ { find: 'foo', replace: 'bar', replaceAll: true },
+ ]);
+
+ expect(result.content[0].content[0].text).toBe('bar and bar');
+ expect(result.content[1].content[0].text).toBe('also bar');
+ // 2 + 1 occurrences counted.
+ expect(results).toEqual([{ find: 'foo', replacements: 3 }]);
+ });
+ });
+
+ describe('multi-match without replaceAll', () => {
+ it('throws reporting the match count', () => {
+ const doc = singleParagraph('x x x');
+ expect(() => applyTextEdits(doc, [{ find: 'x', replace: 'y' }])).toThrow(
+ /matches 3 times/,
+ );
+ });
+
+ it('counts matches across nodes when deciding to throw', () => {
+ const doc = {
+ type: 'doc',
+ content: [
+ { type: 'paragraph', content: [text('dup')] },
+ { type: 'paragraph', content: [text('dup')] },
+ ],
+ };
+ expect(() => applyTextEdits(doc, [{ find: 'dup', replace: 'z' }])).toThrow(
+ /matches 2 times/,
+ );
+ });
+ });
+
+ describe('zero match', () => {
+ it('throws "text not found" when text is absent entirely', () => {
+ const doc = singleParagraph('hello world');
+ expect(() =>
+ applyTextEdits(doc, [{ find: 'absent', replace: 'x' }]),
+ ).toThrow(/text not found in the document/);
+ });
+ });
+
+ describe('text split across formatting runs', () => {
+ it('throws a distinct "spans multiple formatting runs" error', () => {
+ // "hello world" exists in the concatenated text but no single text node
+ // contains it: it is split across a bold run and a plain run.
+ const doc = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ text('hello ', { marks: [{ type: 'bold' }] }),
+ text('world'),
+ ],
+ },
+ ],
+ };
+ expect(() =>
+ applyTextEdits(doc, [{ find: 'hello world', replace: 'x' }]),
+ ).toThrow(/spans multiple formatting runs/);
+ });
+
+ it('does not raise the spans error when text is genuinely missing', () => {
+ const doc = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [text('hello ', { marks: [{ type: 'bold' }] }), text('world')],
+ },
+ ],
+ };
+ // "nope" is in neither the runs nor the concatenated text.
+ expect(() =>
+ applyTextEdits(doc, [{ find: 'nope', replace: 'x' }]),
+ ).toThrow(/text not found in the document/);
+ });
+ });
+
+ describe('empty find', () => {
+ it('throws on an empty find string', () => {
+ const doc = singleParagraph('hello');
+ expect(() => applyTextEdits(doc, [{ find: '', replace: 'x' }])).toThrow(
+ /edit\.find must be a non-empty string/,
+ );
+ });
+ });
+
+ describe('literal replacement (regex foot-gun)', () => {
+ it('inserts $& literally without regex expansion', () => {
+ const doc = singleParagraph('price NUM here');
+ const { doc: result } = applyTextEdits(doc, [
+ { find: 'NUM', replace: '$&100' },
+ ]);
+ // If String.replace were used, "$&" would expand to the matched "NUM".
+ expect(result.content[0].content[0].text).toBe('price $&100 here');
+ });
+
+ it('inserts $1 literally without regex expansion', () => {
+ const doc = singleParagraph('token TKN end');
+ const { doc: result } = applyTextEdits(doc, [
+ { find: 'TKN', replace: 'a$1b$$c' },
+ ]);
+ expect(result.content[0].content[0].text).toBe('token a$1b$$c end');
+ });
+
+ it('inserts $& literally for replaceAll as well', () => {
+ const doc = singleParagraph('K K');
+ const { doc: result } = applyTextEdits(doc, [
+ { find: 'K', replace: '[$&]', replaceAll: true },
+ ]);
+ expect(result.content[0].content[0].text).toBe('[$&] [$&]');
+ });
+ });
+
+ describe('pruning emptied nodes', () => {
+ it('removes a text node that becomes empty after the edit', () => {
+ const doc = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [text('REMOVE'), text(' keep')],
+ },
+ ],
+ };
+ const { doc: result } = applyTextEdits(doc, [
+ { find: 'REMOVE', replace: '' },
+ ]);
+
+ // The emptied node is pruned, leaving only the surviving node.
+ expect(result.content[0].content).toHaveLength(1);
+ expect(result.content[0].content[0].text).toBe(' keep');
+ });
+ });
+
+ describe('immutability', () => {
+ it('does not mutate the input document', () => {
+ const doc = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ attrs: { id: 'p1' },
+ content: [text('hello world', { marks: [{ type: 'bold' }] })],
+ },
+ ],
+ };
+ const snapshot = structuredClone(doc);
+
+ applyTextEdits(doc, [{ find: 'world', replace: 'there' }]);
+
+ expect(doc).toEqual(snapshot);
+ });
+ });
+
+ describe('deeply nested recursion', () => {
+ it('finds and replaces text several levels deep', () => {
+ const doc = {
+ type: 'doc',
+ content: [
+ {
+ type: 'bulletList',
+ content: [
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'blockquote',
+ content: [
+ {
+ type: 'paragraph',
+ content: [text('deep target value')],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+ const { doc: result, results } = applyTextEdits(doc, [
+ { find: 'target', replace: 'goal' },
+ ]);
+
+ const leaf =
+ result.content[0].content[0].content[0].content[0].content[0]
+ .content[0];
+ expect(leaf.text).toBe('deep goal value');
+ expect(results).toEqual([{ find: 'target', replacements: 1 }]);
+ });
+ });
+
+ describe('multiple edits in sequence', () => {
+ it('applies each edit and returns a result per edit in order', () => {
+ const doc = singleParagraph('alpha beta gamma');
+ const { doc: result, results } = applyTextEdits(doc, [
+ { find: 'alpha', replace: 'A' },
+ { find: 'gamma', replace: 'G' },
+ ]);
+
+ expect(result.content[0].content[0].text).toBe('A beta G');
+ expect(results).toEqual([
+ { find: 'alpha', replacements: 1 },
+ { find: 'gamma', replacements: 1 },
+ ]);
+ });
+ });
+});
diff --git a/test/markdown-converter.test.ts b/test/markdown-converter.test.ts
new file mode 100644
index 0000000..51c34b1
--- /dev/null
+++ b/test/markdown-converter.test.ts
@@ -0,0 +1,507 @@
+import { describe, expect, it } from 'vitest';
+// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
+// collaboration.ts and mutates global DOM at import time).
+import { convertProseMirrorToMarkdown } from '../packages/docmost-client/src/lib/markdown-converter.js';
+
+// Wrap a single node in a minimal ProseMirror doc. The top-level converter
+// joins doc children with "\n\n" and then .trim()s the whole output, so a
+// single-node doc yields exactly that node's rendered (and trimmed) string.
+const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
+// Convenience: a text node, optionally with marks.
+const text = (t: string, marks?: any[]) =>
+ marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
+// Convenience: a paragraph wrapping inline children.
+const para = (...inline: any[]) => ({ type: 'paragraph', content: inline });
+
+describe('convertProseMirrorToMarkdown', () => {
+ // ---------------------------------------------------------------------------
+ describe('headings', () => {
+ it('emits the right number of "#" for levels 1-6', () => {
+ for (let level = 1; level <= 6; level++) {
+ const out = convertProseMirrorToMarkdown(
+ doc({ type: 'heading', attrs: { level }, content: [text('H')] }),
+ );
+ expect(out).toBe('#'.repeat(level) + ' H');
+ }
+ });
+
+ it('defaults to level 1 when level is missing', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({ type: 'heading', content: [text('NoLevel')] }),
+ );
+ expect(out).toBe('# NoLevel');
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ describe('text marks', () => {
+ it('bold', () => {
+ expect(
+ convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'bold' }])))),
+ ).toBe('**x**');
+ });
+
+ it('italic', () => {
+ expect(
+ convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'italic' }])))),
+ ).toBe('*x*');
+ });
+
+ it('strike', () => {
+ expect(
+ convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'strike' }])))),
+ ).toBe('~~x~~');
+ });
+
+ it('inline code (sole mark) uses backtick span', () => {
+ expect(
+ convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'code' }])))),
+ ).toBe('`x`');
+ });
+
+ it('code + another mark switches to nested HTML (no backtick form)', () => {
+ // marks array order drives nesting: bold first wraps, then code wraps that.
+ const out = convertProseMirrorToMarkdown(
+ doc(para(text('x', [{ type: 'bold' }, { type: 'code' }]))),
+ );
+ expect(out).toBe('x');
+ });
+
+ it('code + strike combo emits wrapping ', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc(para(text('x', [{ type: 'strike' }, { type: 'code' }]))),
+ );
+ expect(out).toBe('x');
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ describe('links', () => {
+ it('href only', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc(para(text('site', [{ type: 'link', attrs: { href: 'https://e.com' } }]))),
+ );
+ expect(out).toBe('[site](https://e.com)');
+ });
+
+ it('href + title with an embedded double quote is escaped', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc(
+ para(
+ text('site', [
+ { type: 'link', attrs: { href: 'https://e.com', title: 'a "b" c' } },
+ ]),
+ ),
+ ),
+ );
+ // The markdown link-title form escapes the inner " as \".
+ expect(out).toBe('[site](https://e.com "a \\"b\\" c")');
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ describe('image', () => {
+ it('percent-encodes spaces and parentheses in src', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({
+ type: 'image',
+ attrs: { alt: 'cap', src: '/files/my pic (1).png' },
+ }),
+ );
+ // space -> %20, ( -> %28, ) -> %29
+ expect(out).toBe('');
+ });
+
+ it('empty alt and missing src render harmlessly', () => {
+ const out = convertProseMirrorToMarkdown(doc({ type: 'image', attrs: {} }));
+ expect(out).toBe('![]()');
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ describe('codeBlock', () => {
+ it('with language', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({
+ type: 'codeBlock',
+ attrs: { language: 'ts' },
+ content: [text('const a = 1;')],
+ }),
+ );
+ expect(out).toBe('```ts\nconst a = 1;\n```');
+ });
+
+ it('without language emits empty info string', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({ type: 'codeBlock', content: [text('plain')] }),
+ );
+ expect(out).toBe('```\nplain\n```');
+ });
+
+ it('strips ALL trailing newlines for idempotency', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({ type: 'codeBlock', content: [text('a\n\n\n')] }),
+ );
+ // Every trailing "\n" is removed, then exactly one is re-added by the fence.
+ expect(out).toBe('```\na\n```');
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ describe('lists', () => {
+ it('bullet list', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({
+ type: 'bulletList',
+ content: [
+ { type: 'listItem', content: [para(text('one'))] },
+ { type: 'listItem', content: [para(text('two'))] },
+ ],
+ }),
+ );
+ expect(out).toBe('- one\n- two');
+ });
+
+ it('ordered list numbers items sequentially', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({
+ type: 'orderedList',
+ content: [
+ { type: 'listItem', content: [para(text('a'))] },
+ { type: 'listItem', content: [para(text('b'))] },
+ { type: 'listItem', content: [para(text('c'))] },
+ ],
+ }),
+ );
+ expect(out).toBe('1. a\n2. b\n3. c');
+ });
+
+ it('nested bullet list indents the child by the 2-col marker width', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({
+ type: 'bulletList',
+ content: [
+ {
+ type: 'listItem',
+ content: [
+ para(text('parent')),
+ {
+ type: 'bulletList',
+ content: [{ type: 'listItem', content: [para(text('child'))] }],
+ },
+ ],
+ },
+ ],
+ }),
+ );
+ // First line carries the marker; the nested list is indented 2 columns.
+ expect(out).toBe('- parent\n - child');
+ });
+
+ it('nested ordered list indents by the wider 3-col marker width', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({
+ type: 'orderedList',
+ content: [
+ {
+ type: 'listItem',
+ content: [
+ para(text('parent')),
+ {
+ type: 'orderedList',
+ content: [{ type: 'listItem', content: [para(text('child'))] }],
+ },
+ ],
+ },
+ ],
+ }),
+ );
+ // "1. " is 3 columns wide, so the continuation indent is 3 spaces.
+ expect(out).toBe('1. parent\n 1. child');
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ describe('task list', () => {
+ it('unchecked and checked items', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({
+ type: 'taskList',
+ content: [
+ { type: 'taskItem', attrs: { checked: false }, content: [para(text('todo'))] },
+ { type: 'taskItem', attrs: { checked: true }, content: [para(text('done'))] },
+ ],
+ }),
+ );
+ expect(out).toBe('- [ ] todo\n- [x] done');
+ });
+
+ it('empty task item keeps its marker', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({
+ type: 'taskList',
+ content: [{ type: 'taskItem', attrs: { checked: false }, content: [] }],
+ }),
+ );
+ expect(out).toBe('- [ ]');
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ describe('blockquote', () => {
+ it('single paragraph quote prefixes the line', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({ type: 'blockquote', content: [para(text('quoted'))] }),
+ );
+ expect(out).toBe('> quoted');
+ });
+
+ it('multi-paragraph quote separates blocks with a bare ">" line', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({
+ type: 'blockquote',
+ content: [para(text('first')), para(text('second'))],
+ }),
+ );
+ expect(out).toBe('> first\n>\n> second');
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ describe('breaks and rules', () => {
+ it('horizontal rule', () => {
+ expect(
+ convertProseMirrorToMarkdown(doc({ type: 'horizontalRule' })),
+ ).toBe('---');
+ });
+
+ it('hard break emits two trailing spaces then newline', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc(para(text('a'), { type: 'hardBreak' }, text('b'))),
+ );
+ expect(out).toBe('a \nb');
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ describe('tables', () => {
+ it('GFM table emits alignment markers derived from header cells', () => {
+ const headerRow = {
+ type: 'tableRow',
+ content: [
+ { type: 'tableHeader', attrs: { align: 'left' }, content: [para(text('L'))] },
+ { type: 'tableHeader', attrs: { align: 'center' }, content: [para(text('C'))] },
+ { type: 'tableHeader', attrs: { align: 'right' }, content: [para(text('R'))] },
+ { type: 'tableHeader', content: [para(text('N'))] },
+ ],
+ };
+ const bodyRow = {
+ type: 'tableRow',
+ content: [
+ { type: 'tableCell', content: [para(text('1'))] },
+ { type: 'tableCell', content: [para(text('2'))] },
+ { type: 'tableCell', content: [para(text('3'))] },
+ { type: 'tableCell', content: [para(text('4'))] },
+ ],
+ };
+ const out = convertProseMirrorToMarkdown(
+ doc({ type: 'table', content: [headerRow, bodyRow] }),
+ );
+ expect(out).toBe(
+ [
+ '| L | C | R | N |',
+ '| :-- | :-: | --: | --- |',
+ '| 1 | 2 | 3 | 4 |',
+ ].join('\n'),
+ );
+ });
+
+ it('spanned table (colspan/rowspan) emits raw HTML', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({
+ type: 'table',
+ content: [
+ {
+ type: 'tableRow',
+ content: [
+ {
+ type: 'tableHeader',
+ attrs: { colspan: 2 },
+ content: [para(text('wide'))],
+ },
+ ],
+ },
+ {
+ type: 'tableRow',
+ content: [
+ { type: 'tableCell', content: [para(text('a'))] },
+ { type: 'tableCell', content: [para(text('b'))] },
+ ],
+ },
+ ],
+ }),
+ );
+ expect(out).toBe(
+ '' +
+ 'wide |
' +
+ 'a | b |
' +
+ '
',
+ );
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ describe('callout and details', () => {
+ it('callout uses lowercased type fence', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({
+ type: 'callout',
+ attrs: { type: 'WARNING' },
+ content: [para(text('beware'))],
+ }),
+ );
+ expect(out).toBe(':::warning\nbeware\n:::');
+ });
+
+ it('callout defaults to info', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({ type: 'callout', content: [para(text('hi'))] }),
+ );
+ expect(out).toBe(':::info\nhi\n:::');
+ });
+
+ it('details emits summary + content wrapped in ', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({
+ type: 'details',
+ content: [
+ { type: 'detailsSummary', content: [text('Title')] },
+ { type: 'detailsContent', content: [para(text('Body'))] },
+ ],
+ }),
+ );
+ // details joins its children with "\n"; summary opens, content closes.
+ expect(out).toBe('\nTitle
\n\nBody\n ');
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ describe('math', () => {
+ it('inline math carries LaTeX in a text attr WITHOUT escaping < or >', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc(para({ type: 'mathInline', attrs: { text: 'a < b' } })),
+ );
+ // < and > must NOT be HTML-escaped (idempotency); only & and " would be.
+ expect(out).toBe(
+ '',
+ );
+ expect(out).not.toContain('<');
+ });
+
+ it('block math carries LaTeX in a text attr WITHOUT escaping < or >', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({ type: 'mathBlock', attrs: { text: 'x > y & z' } }),
+ );
+ // & IS escaped (entity-significant), but < and > are NOT.
+ expect(out).toBe(
+ '',
+ );
+ expect(out).not.toContain('<');
+ expect(out).not.toContain('>');
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ describe('inline atoms and media', () => {
+ it('mention emits schema span with data-* attrs and visible label', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc(
+ para({
+ type: 'mention',
+ attrs: { id: 'u1', label: 'Alice', entityType: 'user' },
+ }),
+ ),
+ );
+ expect(out).toBe(
+ '@Alice',
+ );
+ });
+
+ it('attachment emits div with schema data-attachment-* attrs', () => {
+ const out = convertProseMirrorToMarkdown(
+ doc({
+ type: 'attachment',
+ attrs: { url: '/files/x.zip', name: 'x.zip', mime: 'application/zip', size: 99 },
+ }),
+ );
+ expect(out).toBe(
+ '',
+ );
+ });
+
+ it('video emits a -wrapped