From 14040d0c5a0885c94307f94b237c1f7f7dd99c1f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:38:48 +0000 Subject: [PATCH 1/2] test(tiptap): add schema validation tests for md2json and json2md Add comprehensive unit tests for the tiptap package to prevent schema validation errors like RangeError when loading content into the editor. Tests include: - md2json image handling (standalone, with title, multiple images, inline) - Nested structures (lists with images, blockquotes, headings) - Edge cases (empty markdown, whitespace, malformed syntax, long URLs) - Mixed content (text + images + lists + code blocks + task lists) - Schema validation using getSchema().nodeFromJSON() - Roundtrip validation (md -> json -> md -> json) - isValidTiptapContent function tests Also adds schema-validation.ts with reusable validation utilities: - validateJsonContent(): Returns validation result - assertValidSchema(): Throws on invalid content Related: #3245 Co-Authored-By: yujonglee --- packages/tiptap/src/shared/index.ts | 1 + .../tiptap/src/shared/schema-validation.ts | 28 ++ packages/tiptap/src/shared/utils.test.ts | 463 +++++++++++++++++- 3 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 packages/tiptap/src/shared/schema-validation.ts diff --git a/packages/tiptap/src/shared/index.ts b/packages/tiptap/src/shared/index.ts index 8ee41f039c..f8f5247e5a 100644 --- a/packages/tiptap/src/shared/index.ts +++ b/packages/tiptap/src/shared/index.ts @@ -1,4 +1,5 @@ export * from "./animation"; export * from "./extensions"; export * from "./hashtag"; +export * from "./schema-validation"; export * from "./utils"; diff --git a/packages/tiptap/src/shared/schema-validation.ts b/packages/tiptap/src/shared/schema-validation.ts new file mode 100644 index 0000000000..beb3812352 --- /dev/null +++ b/packages/tiptap/src/shared/schema-validation.ts @@ -0,0 +1,28 @@ +import { getSchema } from "@tiptap/core"; +import type { JSONContent } from "@tiptap/react"; + +import { getExtensions } from "./extensions"; + +export type SchemaValidationResult = + | { valid: true } + | { valid: false; error: string }; + +export function validateJsonContent(json: JSONContent): SchemaValidationResult { + try { + const schema = getSchema(getExtensions()); + schema.nodeFromJSON(json); + return { valid: true }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export function assertValidSchema(json: JSONContent): void { + const result = validateJsonContent(json); + if (!result.valid) { + throw new Error(`Schema validation failed: ${result.error}`); + } +} diff --git a/packages/tiptap/src/shared/utils.test.ts b/packages/tiptap/src/shared/utils.test.ts index 5b595de6d9..a4b7bd9989 100644 --- a/packages/tiptap/src/shared/utils.test.ts +++ b/packages/tiptap/src/shared/utils.test.ts @@ -1,6 +1,9 @@ +import { getSchema } from "@tiptap/core"; +import type { JSONContent } from "@tiptap/react"; import { describe, expect, test } from "vitest"; -import { json2md } from "./utils"; +import { getExtensions } from "./extensions"; +import { isValidTiptapContent, json2md, md2json } from "./utils"; describe("json2md", () => { test("renders task items without escaping brackets", () => { @@ -118,3 +121,461 @@ describe("json2md", () => { expect(markdown).toContain("third task"); }); }); + +describe("md2json", () => { + describe("image handling", () => { + test("converts standalone image to JSON", () => { + const markdown = "![alt text](https://example.com/image.png)"; + const json = md2json(markdown); + + expect(json.type).toBe("doc"); + expect(json.content).toBeDefined(); + expect(json.content!.length).toBeGreaterThan(0); + + const findImage = (content: any[]): any => { + for (const node of content) { + if (node.type === "image") return node; + if (node.content) { + const found = findImage(node.content); + if (found) return found; + } + } + return null; + }; + + const imageNode = findImage(json.content!); + expect(imageNode).toBeDefined(); + expect(imageNode?.attrs?.src).toBe("https://example.com/image.png"); + expect(imageNode?.attrs?.alt).toBe("alt text"); + }); + + test("converts image with title to JSON", () => { + const markdown = + '![alt text](https://example.com/image.png "Image Title")'; + const json = md2json(markdown); + + const findImage = (content: any[]): any => { + for (const node of content) { + if (node.type === "image") return node; + if (node.content) { + const found = findImage(node.content); + if (found) return found; + } + } + return null; + }; + + const imageNode = findImage(json.content!); + expect(imageNode?.attrs?.src).toBe("https://example.com/image.png"); + expect(imageNode?.attrs?.alt).toBe("alt text"); + expect(imageNode?.attrs?.title).toBe("Image Title"); + }); + + test("converts multiple standalone images to JSON", () => { + const markdown = `![image1](https://example.com/1.png) + +![image2](https://example.com/2.png)`; + const json = md2json(markdown); + + expect(json.content!.length).toBeGreaterThanOrEqual(2); + + const findAllImages = (content: any[]): any[] => { + const images: any[] = []; + for (const node of content) { + if (node.type === "image") images.push(node); + if (node.content) { + images.push(...findAllImages(node.content)); + } + } + return images; + }; + + const images = findAllImages(json.content!); + expect(images.length).toBeGreaterThanOrEqual(2); + }); + + test("converts text with inline image to valid schema", () => { + const markdown = + "Check out this image: ![cat](https://example.com/cat.png) and more text"; + const json = md2json(markdown); + + const paragraph = json.content![0]; + expect(paragraph.type).toBe("paragraph"); + + const imageNode = paragraph.content!.find( + (node) => node.type === "image", + ); + expect(imageNode).toBeDefined(); + expect(imageNode?.attrs?.src).toBe("https://example.com/cat.png"); + expect(imageNode?.attrs?.alt).toBe("cat"); + + const textNodes = paragraph.content!.filter( + (node) => node.type === "text", + ); + expect(textNodes.length).toBeGreaterThan(0); + }); + }); + + describe("nested structures", () => { + test("converts nested lists with images", () => { + const markdown = `- Item 1 + - ![nested](https://example.com/nested.png) + - Item 1.2 +- Item 2`; + const json = md2json(markdown); + + expect(json.type).toBe("doc"); + expect(json.content).toBeDefined(); + }); + + test("converts blockquote with image", () => { + const markdown = `> This is a quote +> ![quote image](https://example.com/quote.png)`; + const json = md2json(markdown); + + expect(json.type).toBe("doc"); + expect(json.content).toBeDefined(); + }); + + test("converts heading with following image", () => { + const markdown = `# Title + +![header image](https://example.com/header.png) + +Some text`; + const json = md2json(markdown); + + expect(json.content!.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe("edge cases", () => { + test("handles empty markdown", () => { + const markdown = ""; + const json = md2json(markdown); + + expect(json.type).toBe("doc"); + expect(json.content).toBeDefined(); + }); + + test("handles whitespace-only markdown", () => { + const markdown = " \n\n "; + const json = md2json(markdown); + + expect(json.type).toBe("doc"); + }); + + test("handles malformed image syntax", () => { + const markdown = "![incomplete image]("; + const json = md2json(markdown); + + expect(json.type).toBe("doc"); + expect(json.content).toBeDefined(); + }); + + test("handles image with no alt text", () => { + const markdown = "![](https://example.com/no-alt.png)"; + const json = md2json(markdown); + + const findImage = (content: any[]): any => { + for (const node of content) { + if (node.type === "image") return node; + if (node.content) { + const found = findImage(node.content); + if (found) return found; + } + } + return null; + }; + + const imageNode = findImage(json.content!); + expect(imageNode).toBeDefined(); + expect(imageNode?.attrs?.src).toBe("https://example.com/no-alt.png"); + expect(imageNode?.attrs?.alt).toBe(""); + }); + + test("handles very long URLs", () => { + const longUrl = "https://example.com/" + "a".repeat(1000) + ".png"; + const markdown = `![long url](${longUrl})`; + const json = md2json(markdown); + + const findImage = (content: any[]): any => { + for (const node of content) { + if (node.type === "image") return node; + if (node.content) { + const found = findImage(node.content); + if (found) return found; + } + } + return null; + }; + + const imageNode = findImage(json.content!); + expect(imageNode?.attrs?.src).toBe(longUrl); + }); + }); + + describe("mixed content", () => { + test("converts document with text, images, and lists", () => { + const markdown = `# Introduction + +Here is some text. + +![diagram](https://example.com/diagram.png) + +- List item 1 +- List item 2 + +More text here.`; + + const json = md2json(markdown); + + expect(json.type).toBe("doc"); + expect(json.content!.length).toBeGreaterThan(3); + }); + + test("converts document with code blocks and images", () => { + const markdown = `Some code: + +\`\`\`javascript +console.log("hello"); +\`\`\` + +![screenshot](https://example.com/screenshot.png)`; + + const json = md2json(markdown); + + expect(json.type).toBe("doc"); + expect(json.content).toBeDefined(); + }); + + test("converts task list with images", () => { + const markdown = `- [ ] Task 1 +- [x] Task 2 ![done](https://example.com/check.png) +- [ ] Task 3`; + + const json = md2json(markdown); + + const taskList = json.content!.find((node) => node.type === "taskList"); + expect(taskList).toBeDefined(); + }); + }); +}); + +describe("schema validation", () => { + const schema = getSchema(getExtensions()); + + function validateJsonContent(json: JSONContent): { + valid: boolean; + error?: string; + } { + try { + schema.nodeFromJSON(json); + return { valid: true }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + describe("md2json produces valid content", () => { + test("standalone image markdown produces schema-valid JSON", () => { + const markdown = "![alt](https://example.com/image.png)"; + const json = md2json(markdown); + + const validation = validateJsonContent(json); + expect(validation.valid).toBe(true); + if (!validation.valid) { + throw new Error(`Schema validation failed: ${validation.error}`); + } + }); + + test("multiple images produce schema-valid JSON", () => { + const markdown = `![img1](https://example.com/1.png) + +![img2](https://example.com/2.png) + +![img3](https://example.com/3.png)`; + const json = md2json(markdown); + + const validation = validateJsonContent(json); + expect(validation.valid).toBe(true); + if (!validation.valid) { + throw new Error(`Schema validation failed: ${validation.error}`); + } + }); + + test("mixed content produces schema-valid JSON", () => { + const markdown = `# Heading + +Text paragraph. + +![image](https://example.com/img.png) + +- List item 1 +- List item 2 + +More text.`; + const json = md2json(markdown); + + const validation = validateJsonContent(json); + expect(validation.valid).toBe(true); + if (!validation.valid) { + throw new Error(`Schema validation failed: ${validation.error}`); + } + }); + + test("inline image in text produces schema-valid JSON", () => { + const markdown = + "Here is an image ![inline](https://example.com/inline.png) in text."; + const json = md2json(markdown); + + const validation = validateJsonContent(json); + expect(validation.valid).toBe(true); + if (!validation.valid) { + throw new Error(`Schema validation failed: ${validation.error}`); + } + }); + + test("task list produces schema-valid JSON", () => { + const markdown = `- [ ] Task 1 +- [x] Task 2 +- [ ] Task 3`; + const json = md2json(markdown); + + const validation = validateJsonContent(json); + expect(validation.valid).toBe(true); + if (!validation.valid) { + throw new Error(`Schema validation failed: ${validation.error}`); + } + }); + + test("empty document produces schema-valid JSON", () => { + const markdown = ""; + const json = md2json(markdown); + + const validation = validateJsonContent(json); + expect(validation.valid).toBe(true); + if (!validation.valid) { + throw new Error(`Schema validation failed: ${validation.error}`); + } + }); + + test("nested structures produce schema-valid JSON", () => { + const markdown = `> Blockquote with ![image](https://example.com/quote.png) inside + +# Heading + +1. Numbered list +2. With items`; + const json = md2json(markdown); + + const validation = validateJsonContent(json); + expect(validation.valid).toBe(true); + if (!validation.valid) { + throw new Error(`Schema validation failed: ${validation.error}`); + } + }); + }); + + describe("invalid content detection", () => { + test("detects invalid node types", () => { + const invalidJson: JSONContent = { + type: "doc", + content: [ + { + type: "invalidNodeType", + content: [], + } as any, + ], + }; + + const validation = validateJsonContent(invalidJson); + expect(validation.valid).toBe(false); + }); + + test("detects invalid doc structure (missing content)", () => { + const invalidJson = { + type: "doc", + } as JSONContent; + + const validation = validateJsonContent(invalidJson); + expect(validation.valid).toBe(true); + }); + + test("validates image with src attribute", () => { + const validJson: JSONContent = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "image", + attrs: { + src: "https://example.com/image.png", + }, + }, + ], + }, + ], + }; + + const validation = validateJsonContent(validJson); + expect(validation.valid).toBe(true); + }); + }); + + describe("roundtrip validation", () => { + test("markdown -> json -> markdown -> json produces consistent valid schema", () => { + const originalMarkdown = `# Test Document + +![image](https://example.com/test.png) + +- List item +- Another item + +Some text.`; + + const json1 = md2json(originalMarkdown); + const validation1 = validateJsonContent(json1); + expect(validation1.valid).toBe(true); + + const markdown2 = json2md(json1); + const json2 = md2json(markdown2); + const validation2 = validateJsonContent(json2); + expect(validation2.valid).toBe(true); + }); + }); +}); + +describe("isValidTiptapContent", () => { + test("returns true for valid content", () => { + const validContent = { + type: "doc", + content: [{ type: "paragraph" }], + }; + expect(isValidTiptapContent(validContent)).toBe(true); + }); + + test("returns false for non-object", () => { + expect(isValidTiptapContent("string")).toBe(false); + expect(isValidTiptapContent(123)).toBe(false); + expect(isValidTiptapContent(null)).toBe(false); + expect(isValidTiptapContent(undefined)).toBe(false); + }); + + test("returns false for object without type: doc", () => { + expect(isValidTiptapContent({ type: "paragraph" })).toBe(false); + expect(isValidTiptapContent({ content: [] })).toBe(false); + }); + + test("returns false for doc without content array", () => { + expect(isValidTiptapContent({ type: "doc" })).toBe(false); + expect(isValidTiptapContent({ type: "doc", content: "string" })).toBe( + false, + ); + }); +}); From f8f2cd977a7aae173a7e55aae859acf9528a276d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:27:23 +0000 Subject: [PATCH 2/2] fix(tiptap): wrap inline nodes in paragraphs to prevent RangeError Add wrapInlineNodesInParagraphs() function that post-processes parsed JSON to ensure inline nodes (like images) are wrapped in paragraphs instead of being direct children of the doc node. This fixes the RangeError: Invalid content for node doc error that occurred when loading markdown with standalone images. The fix handles: - Standalone images at the doc level - Multiple consecutive images (each wrapped in separate paragraph) - Images mixed with other block content - Nested structures are not affected Also adds tests that specifically verify: - Standalone images are wrapped in paragraphs - Consecutive images are each wrapped separately - Issue #3245 scenario with _memo.md content Fixes #3245 Co-Authored-By: yujonglee --- packages/tiptap/src/shared/utils.test.ts | 86 ++++++++++++++++++++++++ packages/tiptap/src/shared/utils.ts | 29 +++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/packages/tiptap/src/shared/utils.test.ts b/packages/tiptap/src/shared/utils.test.ts index a4b7bd9989..3dde17b990 100644 --- a/packages/tiptap/src/shared/utils.test.ts +++ b/packages/tiptap/src/shared/utils.test.ts @@ -392,6 +392,26 @@ describe("schema validation", () => { } }); + test("standalone image is wrapped in paragraph (not direct child of doc)", () => { + const markdown = "![alt](https://example.com/image.png)"; + const json = md2json(markdown); + + expect(json.type).toBe("doc"); + expect(json.content).toBeDefined(); + expect(json.content!.length).toBeGreaterThan(0); + + const firstChild = json.content![0]; + expect(firstChild.type).toBe("paragraph"); + expect(firstChild.content).toBeDefined(); + + const imageNode = firstChild.content!.find( + (node) => node.type === "image", + ); + expect(imageNode).toBeDefined(); + expect(imageNode?.attrs?.src).toBe("https://example.com/image.png"); + expect(imageNode?.attrs?.alt).toBe("alt"); + }); + test("multiple images produce schema-valid JSON", () => { const markdown = `![img1](https://example.com/1.png) @@ -407,6 +427,37 @@ describe("schema validation", () => { } }); + test("consecutive standalone images are each wrapped in separate paragraphs", () => { + const markdown = `![img1](https://example.com/1.png) + +![img2](https://example.com/2.png)`; + const json = md2json(markdown); + + expect(json.type).toBe("doc"); + expect(json.content).toBeDefined(); + + const paragraphs = json.content!.filter( + (node) => node.type === "paragraph", + ); + expect(paragraphs.length).toBeGreaterThanOrEqual(2); + + const img1Para = paragraphs.find((p) => + p.content?.some( + (n) => + n.type === "image" && n.attrs?.src === "https://example.com/1.png", + ), + ); + const img2Para = paragraphs.find((p) => + p.content?.some( + (n) => + n.type === "image" && n.attrs?.src === "https://example.com/2.png", + ), + ); + + expect(img1Para).toBeDefined(); + expect(img2Para).toBeDefined(); + }); + test("mixed content produces schema-valid JSON", () => { const markdown = `# Heading @@ -548,6 +599,41 @@ Some text.`; const validation2 = validateJsonContent(json2); expect(validation2.valid).toBe(true); }); + + test("issue #3245: _memo.md with standalone image produces valid schema", () => { + const memoMarkdown = `![welcome](https://example.com/welcome.png) + +We appreciate your patience while you wait.`; + + const json = md2json(memoMarkdown); + const validation = validateJsonContent(json); + + expect(validation.valid).toBe(true); + if (!validation.valid) { + throw new Error(`Schema validation failed: ${validation.error}`); + } + + expect(json.content!.length).toBeGreaterThanOrEqual(2); + + const firstNode = json.content![0]; + expect(firstNode.type).toBe("paragraph"); + expect(firstNode.content).toBeDefined(); + + const imageInFirstPara = firstNode.content!.find( + (n) => n.type === "image", + ); + expect(imageInFirstPara).toBeDefined(); + expect(imageInFirstPara?.attrs?.src).toBe( + "https://example.com/welcome.png", + ); + + const secondNode = json.content![1]; + expect(secondNode.type).toBe("paragraph"); + const textInSecondPara = secondNode.content?.find( + (n) => n.type === "text", + ); + expect(textInSecondPara).toBeDefined(); + }); }); }); diff --git a/packages/tiptap/src/shared/utils.ts b/packages/tiptap/src/shared/utils.ts index 7af56c37f3..167d6e81aa 100644 --- a/packages/tiptap/src/shared/utils.ts +++ b/packages/tiptap/src/shared/utils.ts @@ -22,10 +22,37 @@ export function json2md(jsonContent: JSONContent): string { return manager.serialize(jsonContent); } +function wrapInlineNodesInParagraphs(json: JSONContent): JSONContent { + if (json.type !== "doc" || !json.content) { + return json; + } + + const wrappedContent: JSONContent[] = []; + + for (const node of json.content) { + const isInlineNode = node.type === "image" || node.type === "text"; + + if (isInlineNode) { + wrappedContent.push({ + type: "paragraph", + content: [node], + }); + } else { + wrappedContent.push(node); + } + } + + return { + ...json, + content: wrappedContent, + }; +} + export function md2json(markdown: string): JSONContent { try { const manager = new MarkdownManager({ extensions: getExtensions() }); - return manager.parse(markdown); + const parsed = manager.parse(markdown); + return wrapInlineNodesInParagraphs(parsed); } catch (error) { console.error(error);