fantsec-docmost-cli 2.2.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/build/__tests__/cli-utils.test.js +287 -0
  4. package/build/__tests__/client-pagination.test.js +103 -0
  5. package/build/__tests__/discovery.test.js +40 -0
  6. package/build/__tests__/envelope.test.js +91 -0
  7. package/build/__tests__/filters.test.js +235 -0
  8. package/build/__tests__/integration/comment.test.js +48 -0
  9. package/build/__tests__/integration/discovery.test.js +24 -0
  10. package/build/__tests__/integration/file.test.js +33 -0
  11. package/build/__tests__/integration/group.test.js +48 -0
  12. package/build/__tests__/integration/helpers/global-setup.js +80 -0
  13. package/build/__tests__/integration/helpers/run-cli.js +163 -0
  14. package/build/__tests__/integration/invite.test.js +34 -0
  15. package/build/__tests__/integration/page.test.js +69 -0
  16. package/build/__tests__/integration/search.test.js +45 -0
  17. package/build/__tests__/integration/share.test.js +49 -0
  18. package/build/__tests__/integration/space.test.js +56 -0
  19. package/build/__tests__/integration/user.test.js +15 -0
  20. package/build/__tests__/integration/workspace.test.js +42 -0
  21. package/build/__tests__/markdown-converter.test.js +445 -0
  22. package/build/__tests__/mcp-tooling.test.js +58 -0
  23. package/build/__tests__/page-mentions.test.js +65 -0
  24. package/build/__tests__/tiptap-extensions.test.js +135 -0
  25. package/build/client.js +715 -0
  26. package/build/commands/comment.js +54 -0
  27. package/build/commands/discovery.js +21 -0
  28. package/build/commands/file.js +36 -0
  29. package/build/commands/group.js +91 -0
  30. package/build/commands/invite.js +67 -0
  31. package/build/commands/page.js +227 -0
  32. package/build/commands/search.js +33 -0
  33. package/build/commands/share.js +65 -0
  34. package/build/commands/space.js +154 -0
  35. package/build/commands/user.js +38 -0
  36. package/build/commands/workspace.js +77 -0
  37. package/build/index.js +19 -0
  38. package/build/lib/auth-utils.js +53 -0
  39. package/build/lib/cli-utils.js +293 -0
  40. package/build/lib/collaboration.js +126 -0
  41. package/build/lib/filters.js +137 -0
  42. package/build/lib/markdown-converter.js +187 -0
  43. package/build/lib/mcp-tooling.js +295 -0
  44. package/build/lib/page-mentions.js +162 -0
  45. package/build/lib/tiptap-extensions.js +86 -0
  46. package/build/mcp.js +186 -0
  47. package/build/program.js +60 -0
  48. package/package.json +64 -0
@@ -0,0 +1,445 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js";
3
+ // Helper to build a minimal doc with given content nodes
4
+ const doc = (...nodes) => ({ type: "doc", content: nodes });
5
+ const p = (...children) => ({ type: "paragraph", content: children });
6
+ const txt = (text, marks) => ({ type: "text", text, ...(marks ? { marks } : {}) });
7
+ describe("convertProseMirrorToMarkdown", () => {
8
+ // ── Edge cases ──────────────────────────────────────────────────────
9
+ describe("edge cases", () => {
10
+ it("returns empty string for null input", () => {
11
+ expect(convertProseMirrorToMarkdown(null)).toBe("");
12
+ });
13
+ it("returns empty string for undefined input", () => {
14
+ expect(convertProseMirrorToMarkdown(undefined)).toBe("");
15
+ });
16
+ it("returns empty string for object without content", () => {
17
+ expect(convertProseMirrorToMarkdown({ type: "doc" })).toBe("");
18
+ });
19
+ it("returns empty string for empty content array", () => {
20
+ expect(convertProseMirrorToMarkdown({ type: "doc", content: [] })).toBe("");
21
+ });
22
+ it("returns empty string for paragraph with no children", () => {
23
+ expect(convertProseMirrorToMarkdown(doc({ type: "paragraph" }))).toBe("");
24
+ });
25
+ });
26
+ // ── Basic nodes ─────────────────────────────────────────────────────
27
+ describe("paragraph", () => {
28
+ it("renders plain text", () => {
29
+ expect(convertProseMirrorToMarkdown(doc(p(txt("Hello world"))))).toBe("Hello world");
30
+ });
31
+ it("renders multiple paragraphs separated by blank lines", () => {
32
+ const result = convertProseMirrorToMarkdown(doc(p(txt("First")), p(txt("Second"))));
33
+ expect(result).toBe("First\n\nSecond");
34
+ });
35
+ it("renders textAlign as div", () => {
36
+ const node = { type: "paragraph", attrs: { textAlign: "center" }, content: [txt("Centered")] };
37
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe('<div align="center">Centered</div>');
38
+ });
39
+ it("ignores textAlign=left", () => {
40
+ const node = { type: "paragraph", attrs: { textAlign: "left" }, content: [txt("Normal")] };
41
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("Normal");
42
+ });
43
+ });
44
+ describe("heading", () => {
45
+ for (let level = 1; level <= 6; level++) {
46
+ it(`renders h${level}`, () => {
47
+ const node = { type: "heading", attrs: { level }, content: [txt(`Heading ${level}`)] };
48
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe(`${"#".repeat(level)} Heading ${level}`);
49
+ });
50
+ }
51
+ it("defaults to level 1 when no attrs", () => {
52
+ const node = { type: "heading", content: [txt("Default")] };
53
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("# Default");
54
+ });
55
+ });
56
+ describe("hardBreak", () => {
57
+ it("renders as newline", () => {
58
+ const result = convertProseMirrorToMarkdown(doc(p(txt("Line 1"), { type: "hardBreak" }, txt("Line 2"))));
59
+ expect(result).toBe("Line 1\nLine 2");
60
+ });
61
+ });
62
+ describe("horizontalRule", () => {
63
+ it("renders as ---", () => {
64
+ expect(convertProseMirrorToMarkdown(doc({ type: "horizontalRule" }))).toBe("---");
65
+ });
66
+ });
67
+ // ── Text marks ──────────────────────────────────────────────────────
68
+ describe("text marks", () => {
69
+ it("bold", () => {
70
+ expect(convertProseMirrorToMarkdown(doc(p(txt("bold", [{ type: "bold" }]))))).toBe("**bold**");
71
+ });
72
+ it("italic", () => {
73
+ expect(convertProseMirrorToMarkdown(doc(p(txt("italic", [{ type: "italic" }]))))).toBe("*italic*");
74
+ });
75
+ it("code", () => {
76
+ expect(convertProseMirrorToMarkdown(doc(p(txt("code", [{ type: "code" }]))))).toBe("`code`");
77
+ });
78
+ it("strike", () => {
79
+ expect(convertProseMirrorToMarkdown(doc(p(txt("struck", [{ type: "strike" }]))))).toBe("~~struck~~");
80
+ });
81
+ it("underline", () => {
82
+ expect(convertProseMirrorToMarkdown(doc(p(txt("underlined", [{ type: "underline" }]))))).toBe("<u>underlined</u>");
83
+ });
84
+ it("subscript", () => {
85
+ expect(convertProseMirrorToMarkdown(doc(p(txt("sub", [{ type: "subscript" }]))))).toBe("<sub>sub</sub>");
86
+ });
87
+ it("superscript", () => {
88
+ expect(convertProseMirrorToMarkdown(doc(p(txt("sup", [{ type: "superscript" }]))))).toBe("<sup>sup</sup>");
89
+ });
90
+ it("link", () => {
91
+ const mark = { type: "link", attrs: { href: "https://example.com" } };
92
+ expect(convertProseMirrorToMarkdown(doc(p(txt("click", [mark]))))).toBe("[click](https://example.com)");
93
+ });
94
+ it("link with no href", () => {
95
+ const mark = { type: "link", attrs: {} };
96
+ expect(convertProseMirrorToMarkdown(doc(p(txt("click", [mark]))))).toBe("[click]()");
97
+ });
98
+ it("highlight with default color", () => {
99
+ const mark = { type: "highlight" };
100
+ expect(convertProseMirrorToMarkdown(doc(p(txt("hi", [mark]))))).toBe('<mark style="background-color: yellow">hi</mark>');
101
+ });
102
+ it("highlight with custom color", () => {
103
+ const mark = { type: "highlight", attrs: { color: "red" } };
104
+ expect(convertProseMirrorToMarkdown(doc(p(txt("hi", [mark]))))).toBe('<mark style="background-color: red">hi</mark>');
105
+ });
106
+ it("textStyle with color", () => {
107
+ const mark = { type: "textStyle", attrs: { color: "#ff0000" } };
108
+ expect(convertProseMirrorToMarkdown(doc(p(txt("colored", [mark]))))).toBe('<span style="color: #ff0000">colored</span>');
109
+ });
110
+ it("textStyle without color does not wrap", () => {
111
+ const mark = { type: "textStyle", attrs: {} };
112
+ expect(convertProseMirrorToMarkdown(doc(p(txt("plain", [mark]))))).toBe("plain");
113
+ });
114
+ it("nested marks: bold+italic", () => {
115
+ const marks = [{ type: "bold" }, { type: "italic" }];
116
+ expect(convertProseMirrorToMarkdown(doc(p(txt("both", marks))))).toBe("***both***");
117
+ });
118
+ it("nested marks: bold+link", () => {
119
+ const marks = [{ type: "bold" }, { type: "link", attrs: { href: "https://x.com" } }];
120
+ expect(convertProseMirrorToMarkdown(doc(p(txt("link", marks))))).toBe("[**link**](https://x.com)");
121
+ });
122
+ });
123
+ // ── Lists ───────────────────────────────────────────────────────────
124
+ describe("bulletList", () => {
125
+ it("renders items with dashes", () => {
126
+ const list = {
127
+ type: "bulletList",
128
+ content: [
129
+ { type: "listItem", content: [p(txt("Item 1"))] },
130
+ { type: "listItem", content: [p(txt("Item 2"))] },
131
+ ],
132
+ };
133
+ expect(convertProseMirrorToMarkdown(doc(list))).toBe("- Item 1\n- Item 2");
134
+ });
135
+ });
136
+ describe("orderedList", () => {
137
+ it("renders items with numbers", () => {
138
+ const list = {
139
+ type: "orderedList",
140
+ content: [
141
+ { type: "listItem", content: [p(txt("First"))] },
142
+ { type: "listItem", content: [p(txt("Second"))] },
143
+ ],
144
+ };
145
+ expect(convertProseMirrorToMarkdown(doc(list))).toBe("1. First\n2. Second");
146
+ });
147
+ });
148
+ describe("taskList", () => {
149
+ it("renders unchecked items", () => {
150
+ const list = {
151
+ type: "taskList",
152
+ content: [
153
+ { type: "taskItem", attrs: { checked: false }, content: [p(txt("Todo"))] },
154
+ ],
155
+ };
156
+ expect(convertProseMirrorToMarkdown(doc(list))).toBe("- [ ] Todo");
157
+ });
158
+ it("renders checked items", () => {
159
+ const list = {
160
+ type: "taskList",
161
+ content: [
162
+ { type: "taskItem", attrs: { checked: true }, content: [p(txt("Done"))] },
163
+ ],
164
+ };
165
+ expect(convertProseMirrorToMarkdown(doc(list))).toBe("- [x] Done");
166
+ });
167
+ it("renders mixed checked/unchecked", () => {
168
+ const list = {
169
+ type: "taskList",
170
+ content: [
171
+ { type: "taskItem", attrs: { checked: true }, content: [p(txt("Done"))] },
172
+ { type: "taskItem", attrs: { checked: false }, content: [p(txt("Pending"))] },
173
+ ],
174
+ };
175
+ expect(convertProseMirrorToMarkdown(doc(list))).toBe("- [x] Done\n- [ ] Pending");
176
+ });
177
+ });
178
+ // ── Code blocks ─────────────────────────────────────────────────────
179
+ describe("codeBlock", () => {
180
+ it("renders without language", () => {
181
+ const node = { type: "codeBlock", content: [txt("const x = 1;")] };
182
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("```\nconst x = 1;\n```");
183
+ });
184
+ it("renders with language", () => {
185
+ const node = { type: "codeBlock", attrs: { language: "typescript" }, content: [txt("const x: number = 1;")] };
186
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("```typescript\nconst x: number = 1;\n```");
187
+ });
188
+ it("renders empty code block", () => {
189
+ const node = { type: "codeBlock" };
190
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("```\n\n```");
191
+ });
192
+ });
193
+ // ── Blockquote ──────────────────────────────────────────────────────
194
+ describe("blockquote", () => {
195
+ it("renders single paragraph", () => {
196
+ const node = { type: "blockquote", content: [p(txt("Quote"))] };
197
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("> Quote");
198
+ });
199
+ it("renders multi-paragraph", () => {
200
+ const node = { type: "blockquote", content: [p(txt("Line 1")), p(txt("Line 2"))] };
201
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("> Line 1\n> Line 2");
202
+ });
203
+ });
204
+ // ── Images ──────────────────────────────────────────────────────────
205
+ describe("image", () => {
206
+ it("renders with alt text", () => {
207
+ const node = { type: "image", attrs: { alt: "Photo", src: "https://img.com/a.png" } };
208
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("![Photo](https://img.com/a.png)");
209
+ });
210
+ it("renders without alt text", () => {
211
+ const node = { type: "image", attrs: { src: "https://img.com/a.png" } };
212
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("![](https://img.com/a.png)");
213
+ });
214
+ it("renders with caption", () => {
215
+ const node = { type: "image", attrs: { alt: "Photo", src: "https://img.com/a.png", caption: "A nice photo" } };
216
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("![Photo](https://img.com/a.png)\n*A nice photo*");
217
+ });
218
+ it("renders with empty caption (no caption line)", () => {
219
+ const node = { type: "image", attrs: { alt: "Photo", src: "https://img.com/a.png", caption: "" } };
220
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("![Photo](https://img.com/a.png)");
221
+ });
222
+ });
223
+ // ── Tables ──────────────────────────────────────────────────────────
224
+ describe("table", () => {
225
+ it("renders header + body row", () => {
226
+ const table = {
227
+ type: "table",
228
+ content: [
229
+ {
230
+ type: "tableRow",
231
+ content: [
232
+ { type: "tableHeader", content: [p(txt("Name"))] },
233
+ { type: "tableHeader", content: [p(txt("Age"))] },
234
+ ],
235
+ },
236
+ {
237
+ type: "tableRow",
238
+ content: [
239
+ { type: "tableCell", content: [p(txt("Alice"))] },
240
+ { type: "tableCell", content: [p(txt("30"))] },
241
+ ],
242
+ },
243
+ ],
244
+ };
245
+ const result = convertProseMirrorToMarkdown(doc(table));
246
+ expect(result).toBe("| Name | Age |\n| --- | --- |\n| Alice | 30 |");
247
+ });
248
+ it("renders multiple body rows", () => {
249
+ const table = {
250
+ type: "table",
251
+ content: [
252
+ {
253
+ type: "tableRow",
254
+ content: [
255
+ { type: "tableHeader", content: [p(txt("Col"))] },
256
+ ],
257
+ },
258
+ {
259
+ type: "tableRow",
260
+ content: [{ type: "tableCell", content: [p(txt("A"))] }],
261
+ },
262
+ {
263
+ type: "tableRow",
264
+ content: [{ type: "tableCell", content: [p(txt("B"))] }],
265
+ },
266
+ ],
267
+ };
268
+ const result = convertProseMirrorToMarkdown(doc(table));
269
+ expect(result).toBe("| Col |\n| --- |\n| A |\n| B |");
270
+ });
271
+ it("renders table with no explicit headers (all cells)", () => {
272
+ const table = {
273
+ type: "table",
274
+ content: [
275
+ {
276
+ type: "tableRow",
277
+ content: [
278
+ { type: "tableCell", content: [p(txt("A"))] },
279
+ { type: "tableCell", content: [p(txt("B"))] },
280
+ ],
281
+ },
282
+ {
283
+ type: "tableRow",
284
+ content: [
285
+ { type: "tableCell", content: [p(txt("C"))] },
286
+ { type: "tableCell", content: [p(txt("D"))] },
287
+ ],
288
+ },
289
+ ],
290
+ };
291
+ const result = convertProseMirrorToMarkdown(doc(table));
292
+ // First row treated as header, separator inserted after
293
+ expect(result).toBe("| A | B |\n| --- | --- |\n| C | D |");
294
+ });
295
+ });
296
+ // ── Special nodes ───────────────────────────────────────────────────
297
+ describe("callout", () => {
298
+ it("renders with type", () => {
299
+ const node = { type: "callout", attrs: { type: "warning" }, content: [p(txt("Be careful"))] };
300
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe(":::warning\nBe careful\n:::");
301
+ });
302
+ it("defaults to info type", () => {
303
+ const node = { type: "callout", content: [p(txt("Note"))] };
304
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe(":::info\nNote\n:::");
305
+ });
306
+ it("lowercases the type", () => {
307
+ const node = { type: "callout", attrs: { type: "WARNING" }, content: [p(txt("Caution"))] };
308
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe(":::warning\nCaution\n:::");
309
+ });
310
+ });
311
+ describe("details", () => {
312
+ it("renders details with summary and content", () => {
313
+ const node = {
314
+ type: "details",
315
+ content: [
316
+ { type: "detailsSummary", content: [txt("Click me")] },
317
+ { type: "detailsContent", content: [p(txt("Hidden content"))] },
318
+ ],
319
+ };
320
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("<details>\n<summary>Click me</summary>\n\nHidden content\n</details>");
321
+ });
322
+ });
323
+ describe("mathInline", () => {
324
+ it("renders inline math", () => {
325
+ const node = { type: "mathInline", attrs: { text: "E = mc^2" } };
326
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("$E = mc^2$");
327
+ });
328
+ it("renders empty inline math", () => {
329
+ const node = { type: "mathInline", attrs: {} };
330
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("$$");
331
+ });
332
+ });
333
+ describe("mathBlock", () => {
334
+ it("renders block math", () => {
335
+ const node = { type: "mathBlock", attrs: { text: "\\sum_{i=1}^n x_i" } };
336
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("$$\n\\sum_{i=1}^n x_i\n$$");
337
+ });
338
+ });
339
+ describe("mention", () => {
340
+ it("renders with label", () => {
341
+ const node = { type: "mention", attrs: { label: "John" } };
342
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("@John");
343
+ });
344
+ it("falls back to id", () => {
345
+ const node = { type: "mention", attrs: { id: "user-123" } };
346
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("@user-123");
347
+ });
348
+ it("renders empty when no label or id", () => {
349
+ const node = { type: "mention", attrs: {} };
350
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("@");
351
+ });
352
+ });
353
+ describe("attachment", () => {
354
+ it("renders with filename and url", () => {
355
+ const node = { type: "attachment", attrs: { fileName: "doc.pdf", src: "https://files.com/doc.pdf" } };
356
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("\u{1F4CE} [doc.pdf](https://files.com/doc.pdf)");
357
+ });
358
+ it("defaults filename to attachment", () => {
359
+ const node = { type: "attachment", attrs: { src: "https://files.com/x" } };
360
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("\u{1F4CE} [attachment](https://files.com/x)");
361
+ });
362
+ });
363
+ describe("drawio", () => {
364
+ it("renders placeholder", () => {
365
+ expect(convertProseMirrorToMarkdown(doc({ type: "drawio" }))).toBe("\u{1F4CA} [Draw.io Diagram]");
366
+ });
367
+ });
368
+ describe("excalidraw", () => {
369
+ it("renders placeholder", () => {
370
+ expect(convertProseMirrorToMarkdown(doc({ type: "excalidraw" }))).toBe("\u270F\uFE0F [Excalidraw Drawing]");
371
+ });
372
+ });
373
+ describe("embed", () => {
374
+ it("renders with url", () => {
375
+ const node = { type: "embed", attrs: { src: "https://example.com/embed" } };
376
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("\u{1F517} [Embedded Content](https://example.com/embed)");
377
+ });
378
+ });
379
+ describe("youtube", () => {
380
+ it("renders with url", () => {
381
+ const node = { type: "youtube", attrs: { src: "https://youtube.com/watch?v=abc" } };
382
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("\u{1F4FA} [YouTube Video](https://youtube.com/watch?v=abc)");
383
+ });
384
+ });
385
+ describe("video", () => {
386
+ it("renders with url", () => {
387
+ const node = { type: "video", attrs: { src: "https://cdn.com/video.mp4" } };
388
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("\u{1F3A5} [Video](https://cdn.com/video.mp4)");
389
+ });
390
+ });
391
+ describe("subpages", () => {
392
+ it("renders placeholder", () => {
393
+ expect(convertProseMirrorToMarkdown(doc({ type: "subpages" }))).toBe("{{SUBPAGES}}");
394
+ });
395
+ });
396
+ // ── Security: sanitizeUrl ───────────────────────────────────────────
397
+ describe("sanitizeUrl", () => {
398
+ it("strips javascript: from link href", () => {
399
+ const mark = { type: "link", attrs: { href: "javascript:alert(1)" } };
400
+ expect(convertProseMirrorToMarkdown(doc(p(txt("xss", [mark]))))).toBe("[xss]()");
401
+ });
402
+ it("strips JavaScript: (case insensitive)", () => {
403
+ const mark = { type: "link", attrs: { href: "JavaScript:void(0)" } };
404
+ expect(convertProseMirrorToMarkdown(doc(p(txt("xss", [mark]))))).toBe("[xss]()");
405
+ });
406
+ it("strips data: from image src", () => {
407
+ const node = { type: "image", attrs: { alt: "x", src: "data:text/html,<h1>hi</h1>" } };
408
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("![x]()");
409
+ });
410
+ it("strips vbscript: URLs", () => {
411
+ const mark = { type: "link", attrs: { href: "vbscript:MsgBox" } };
412
+ expect(convertProseMirrorToMarkdown(doc(p(txt("xss", [mark]))))).toBe("[xss]()");
413
+ });
414
+ it("strips data: from video src", () => {
415
+ const node = { type: "video", attrs: { src: "data:video/mp4;base64,AAAA" } };
416
+ expect(convertProseMirrorToMarkdown(doc(node))).toBe("\u{1F3A5} [Video]()");
417
+ });
418
+ it("strips javascript: with leading whitespace", () => {
419
+ const mark = { type: "link", attrs: { href: " javascript:alert(1)" } };
420
+ expect(convertProseMirrorToMarkdown(doc(p(txt("xss", [mark]))))).toBe("[xss]()");
421
+ });
422
+ it("allows https: URLs", () => {
423
+ const mark = { type: "link", attrs: { href: "https://safe.com" } };
424
+ expect(convertProseMirrorToMarkdown(doc(p(txt("ok", [mark]))))).toBe("[ok](https://safe.com)");
425
+ });
426
+ it("allows mailto: URLs", () => {
427
+ const mark = { type: "link", attrs: { href: "mailto:user@example.com" } };
428
+ expect(convertProseMirrorToMarkdown(doc(p(txt("email", [mark]))))).toBe("[email](mailto:user@example.com)");
429
+ });
430
+ });
431
+ // ── HTML attribute escaping ─────────────────────────────────────────
432
+ describe("escapeHtmlAttr", () => {
433
+ it("escapes special chars in textAlign", () => {
434
+ const node = { type: "paragraph", attrs: { textAlign: 'center"><script>' }, content: [txt("XSS")] };
435
+ const result = convertProseMirrorToMarkdown(doc(node));
436
+ expect(result).toBe('<div align="center&quot;&gt;&lt;script&gt;">XSS</div>');
437
+ });
438
+ it("escapes special chars in highlight color", () => {
439
+ const mark = { type: "highlight", attrs: { color: '"><img onerror=alert(1) src=x>' } };
440
+ const result = convertProseMirrorToMarkdown(doc(p(txt("hi", [mark]))));
441
+ expect(result).toContain("&quot;&gt;&lt;img");
442
+ expect(result).not.toContain('"><img');
443
+ });
444
+ });
445
+ });
@@ -0,0 +1,58 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { executeTool, listMcpTools, parseDocmostBearer } from "../lib/mcp-tooling.js";
3
+ const ORIGINAL_ENV = { ...process.env };
4
+ afterEach(() => {
5
+ process.env = { ...ORIGINAL_ENV };
6
+ });
7
+ describe("MCP tooling", () => {
8
+ it("builds a snake_case tool catalog from CLI commands", () => {
9
+ const tools = listMcpTools();
10
+ expect(tools.length).toBeGreaterThan(50);
11
+ expect(tools.some((tool) => tool.commandName === "commands")).toBe(false);
12
+ const pageInfo = tools.find((tool) => tool.commandName === "page-info");
13
+ expect(pageInfo?.toolName).toBe("page_info");
14
+ expect((pageInfo?.inputSchema.pageId).safeParse({}).success).toBe(false);
15
+ });
16
+ it("requires output path for binary-producing commands in MCP mode", () => {
17
+ const tools = listMcpTools();
18
+ const pageExport = tools.find((tool) => tool.commandName === "page-export");
19
+ expect(pageExport?.requiresOutputPath).toBe(true);
20
+ expect(pageExport?.description).toContain("requires `output`");
21
+ expect((pageExport?.inputSchema.output).safeParse(undefined).success).toBe(false);
22
+ });
23
+ it("maps true/false choice options to booleans", () => {
24
+ const tools = listMcpTools();
25
+ const shareCreate = tools.find((tool) => tool.commandName === "share-create");
26
+ expect(shareCreate).toBeDefined();
27
+ expect((shareCreate?.inputSchema.includeSubpages).safeParse(true).success).toBe(true);
28
+ expect((shareCreate?.inputSchema.includeSubpages).safeParse("true").success).toBe(false);
29
+ });
30
+ it("returns CLI validation errors as MCP-friendly results", async () => {
31
+ delete process.env.DOCMOST_API_URL;
32
+ delete process.env.DOCMOST_TOKEN;
33
+ delete process.env.DOCMOST_EMAIL;
34
+ delete process.env.DOCMOST_PASSWORD;
35
+ const tools = listMcpTools();
36
+ const workspacePublic = tools.find((tool) => tool.commandName === "workspace-public");
37
+ expect(workspacePublic).toBeDefined();
38
+ const result = await executeTool(workspacePublic, {});
39
+ const parsed = result.parsed;
40
+ expect(result.ok).toBe(false);
41
+ expect(parsed.ok).toBe(false);
42
+ expect(parsed.error.code).toBe("VALIDATION_ERROR");
43
+ expect(parsed.error.message).toContain("API URL is required");
44
+ });
45
+ it("parses bearer tokens as either docmost API tokens or email/password pairs", () => {
46
+ const tokenAuth = parseDocmostBearer("token-123", "https://docs.example.com/api");
47
+ expect(tokenAuth).toEqual({
48
+ apiUrl: "https://docs.example.com/api",
49
+ token: "token-123",
50
+ });
51
+ const passwordAuth = parseDocmostBearer("alice@example.com:secret", "https://docs.example.com/api");
52
+ expect(passwordAuth).toEqual({
53
+ apiUrl: "https://docs.example.com/api",
54
+ email: "alice@example.com",
55
+ password: "secret",
56
+ });
57
+ });
58
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { __internal_extractMentionTokens, markdownToProseMirrorJson, } from "../lib/page-mentions.js";
3
+ describe("page mention markdown conversion", () => {
4
+ it("extracts plain and bracketed mention tokens", () => {
5
+ expect(__internal_extractMentionTokens("参考@通信合同文档 和 @[接口 与 协议 总览]")).toEqual([
6
+ {
7
+ start: 2,
8
+ end: 9,
9
+ raw: "@通信合同文档",
10
+ label: "通信合同文档",
11
+ },
12
+ {
13
+ start: 12,
14
+ end: 25,
15
+ raw: "@[接口 与 协议 总览]",
16
+ label: "接口 与 协议 总览",
17
+ },
18
+ ]);
19
+ });
20
+ it("converts resolved page mentions into mention nodes", async () => {
21
+ const doc = await markdownToProseMirrorJson("参考@通信合同文档", {
22
+ creatorId: "user-1",
23
+ resolvePageMention: async (label) => ({
24
+ id: "page-1",
25
+ title: label,
26
+ slugId: "slug-1",
27
+ }),
28
+ });
29
+ const paragraph = doc.content[0];
30
+ expect(paragraph.content[0]).toEqual({ type: "text", text: "参考" });
31
+ expect(paragraph.content[1]).toEqual({
32
+ type: "mention",
33
+ attrs: {
34
+ id: expect.any(String),
35
+ label: "通信合同文档",
36
+ entityType: "page",
37
+ entityId: "page-1",
38
+ slugId: "slug-1",
39
+ creatorId: "user-1",
40
+ anchorId: null,
41
+ },
42
+ });
43
+ });
44
+ it("keeps unresolved mentions as plain text", async () => {
45
+ const doc = await markdownToProseMirrorJson("参考@通信合同文档", {
46
+ resolvePageMention: async () => null,
47
+ });
48
+ expect(doc.content[0].content[0].text).toBe("参考@通信合同文档");
49
+ });
50
+ it("does not convert mentions inside code or links", async () => {
51
+ const doc = await markdownToProseMirrorJson("`@通信合同文档` [跳转](https://example.com/@通信合同文档) 正文@通信合同文档", {
52
+ resolvePageMention: async (label) => ({
53
+ id: "page-1",
54
+ title: label,
55
+ slugId: "slug-1",
56
+ }),
57
+ });
58
+ const paragraph = doc.content[0].content;
59
+ expect(paragraph.some((node) => node.type === "mention")).toBe(true);
60
+ expect(paragraph[0].type).toBe("text");
61
+ expect(paragraph[0].marks?.[0]?.type).toBe("code");
62
+ const linkNode = paragraph.find((node) => node.text === "跳转");
63
+ expect(linkNode.marks?.[0]?.type).toBe("link");
64
+ });
65
+ });