From 1a5c5f866e27a40193aea26b779cb9e932840f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnau=20G=C3=B3mez=20Farell?= Date: Wed, 4 Jun 2025 10:58:20 +0200 Subject: [PATCH 1/4] feat: add config option to emit content error when content check is disabled --- packages/core/src/Editor.ts | 1 + packages/core/src/commands/insertContentAt.ts | 23 ++++++++------ .../core/src/helpers/createNodeFromContent.ts | 31 +++++++++++++++---- packages/core/src/types.ts | 9 ++++++ 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/core/src/Editor.ts b/packages/core/src/Editor.ts index 7a5572146..beee261e5 100644 --- a/packages/core/src/Editor.ts +++ b/packages/core/src/Editor.ts @@ -81,6 +81,7 @@ export class Editor extends EventEmitter { enablePasteRules: true, enableCoreExtensions: true, enableContentCheck: false, + emitContentError: false, onBeforeCreate: () => null, onCreate: () => null, onUpdate: () => null, diff --git a/packages/core/src/commands/insertContentAt.ts b/packages/core/src/commands/insertContentAt.ts index 33c84524d..58167f3e0 100644 --- a/packages/core/src/commands/insertContentAt.ts +++ b/packages/core/src/commands/insertContentAt.ts @@ -72,6 +72,18 @@ export const insertContentAt: RawCommands['insertContentAt'] = (position, value, let content: Fragment | ProseMirrorNode + const emitContentError = (error: Error) => { + editor.emit('contentError', { + editor, + error, + disableCollaboration: () => { + if (editor.storage.collaboration) { + editor.storage.collaboration.isDisabled = true + } + }, + }) + } + try { content = createNodeFromContent(value, editor.schema, { parseOptions: { @@ -79,17 +91,10 @@ export const insertContentAt: RawCommands['insertContentAt'] = (position, value, ...options.parseOptions, }, errorOnInvalidContent: options.errorOnInvalidContent ?? editor.options.enableContentCheck, + onIgnoredError: editor.options.emitContentError ? emitContentError : undefined, }) } catch (e) { - editor.emit('contentError', { - editor, - error: e as Error, - disableCollaboration: () => { - if (editor.storage.collaboration) { - editor.storage.collaboration.isDisabled = true - } - }, - }) + emitContentError(e as Error) return false } diff --git a/packages/core/src/helpers/createNodeFromContent.ts b/packages/core/src/helpers/createNodeFromContent.ts index 5493b362c..dd3117f5d 100644 --- a/packages/core/src/helpers/createNodeFromContent.ts +++ b/packages/core/src/helpers/createNodeFromContent.ts @@ -13,6 +13,13 @@ export type CreateNodeFromContentOptions = { slice?: boolean parseOptions?: ParseOptions errorOnInvalidContent?: boolean + /** + * Runs if a content is invalid and an error would have been thrown, but + * `errorOnInvalidContent` is `false` so the invalid content is ignored. + * + * @param error The error that was not thrown + */ + onIgnoredError?: (error: Error) => void } /** @@ -56,11 +63,16 @@ export function createNodeFromContent( return node } catch (error) { - if (options.errorOnInvalidContent) { - throw new Error('[tiptap error]: Invalid JSON content', { cause: error as Error }) - } + const thrownError = new Error('[tiptap error]: Invalid JSON content', { cause: error as Error }) - console.warn('[tiptap warn]: Invalid content.', 'Passed value:', content, 'Error:', error) + if (options.errorOnInvalidContent) { + throw thrownError + } + if (options.onIgnoredError) { + options.onIgnoredError(thrownError) + } else { + console.warn('[tiptap warn]: Invalid content.', 'Passed value:', content, 'Error:', error) + } return createNodeFromContent('', schema, options) } @@ -105,8 +117,15 @@ export function createNodeFromContent( DOMParser.fromSchema(contentCheckSchema).parse(elementFromString(content), options.parseOptions) } - if (options.errorOnInvalidContent && hasInvalidContent) { - throw new Error('[tiptap error]: Invalid HTML content', { cause: new Error(`Invalid element found: ${invalidContent}`) }) + if (hasInvalidContent) { + const thrownError = new Error('[tiptap error]: Invalid HTML content', { cause: new Error(`Invalid element found: ${invalidContent}`) }) + + if (options.errorOnInvalidContent) { + throw thrownError + } else if (options.onIgnoredError) { + options.onIgnoredError(thrownError) + } + } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0f231190b..bdf399132 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -127,6 +127,15 @@ export interface EditorOptions { * @default false */ enableContentCheck: boolean; + /** + * If `true`, the editor will emit the `contentError` event invalid content is + * encountered but `enableContentCheck` is `false`. This lets you preserve the + * invalid editor content while still showing a warning or error message to + * the user. + * + * @default false + */ + emitContentError: boolean; onBeforeCreate: (props: EditorEvents['beforeCreate']) => void; onCreate: (props: EditorEvents['create']) => void; /** From 3f8efb9e64580670f65501ff6f656f04e0c37095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnau=20G=C3=B3mez=20Farell?= Date: Wed, 4 Jun 2025 11:06:25 +0200 Subject: [PATCH 2/4] test: add tests and changeset --- .changeset/fresh-ads-nail.md | 5 + .../core/createNodeFromContent.spec.ts | 117 ++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 .changeset/fresh-ads-nail.md diff --git a/.changeset/fresh-ads-nail.md b/.changeset/fresh-ads-nail.md new file mode 100644 index 000000000..ead724dcc --- /dev/null +++ b/.changeset/fresh-ads-nail.md @@ -0,0 +1,5 @@ +--- +"@tiptap/core": minor +--- + +Add config option to emit content error when content check is disabled diff --git a/tests/cypress/integration/core/createNodeFromContent.spec.ts b/tests/cypress/integration/core/createNodeFromContent.spec.ts index b15974820..3293adb1c 100644 --- a/tests/cypress/integration/core/createNodeFromContent.spec.ts +++ b/tests/cypress/integration/core/createNodeFromContent.spec.ts @@ -263,4 +263,121 @@ describe('createNodeFromContent', () => { ]), { errorOnInvalidContent: true }) }).to.throw('[tiptap error]: Invalid JSON content') }) + + it('calls onIgnoredError when a schema does not have matching node types for JSON content', () => { + const content = { + type: 'non-existing-node-type', + content: [{ + type: 'text', + text: 'Example Text', + }], + } + + let errorCalled = false + let errorMessage = '' + + const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ]), { + errorOnInvalidContent: false, + onIgnoredError: error => { + errorCalled = true + errorMessage = error.message + }, + }) + + expect(errorCalled).to.eq(true) + expect(errorMessage).to.eq('[tiptap error]: Invalid JSON content') + expect(fragment.toJSON()).to.deep.eq(null) + }) + + it('calls onIgnoredError when a schema does not have matching node types for HTML content', () => { + const content = 'Example Text' + + let errorCalled = false + let errorMessage = '' + + const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ]), { + errorOnInvalidContent: false, + onIgnoredError: error => { + errorCalled = true + errorMessage = error.message + }, + }) + + expect(errorCalled).to.eq(true) + expect(errorMessage).to.eq('[tiptap error]: Invalid HTML content') + expect(fragment.toJSON()).to.deep.eq([{ type: 'text', text: 'Example Text' }]) + }) + + it('calls onIgnoredError when a schema does not have matching mark types for JSON content', () => { + const content = { + type: 'paragraph', + content: [{ + type: 'text', + text: 'Example Text', + marks: [{ + type: 'non-existing-mark-type', + }], + }], + } + + let errorCalled = false + let errorMessage = '' + + const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ]), { + errorOnInvalidContent: false, + onIgnoredError: error => { + errorCalled = true + errorMessage = error.message + }, + }) + + expect(errorCalled).to.eq(true) + expect(errorMessage).to.eq('[tiptap error]: Invalid JSON content') + expect(fragment.toJSON()).to.deep.eq(null) + }) + + it('calls onIgnoredError when the JSON content does not follow the nesting rules of the schema', () => { + const content = { + type: 'paragraph', + content: [{ + type: 'paragraph', + content: [{ + type: 'text', + text: 'Example Text', + }], + }], + } + + let errorCalled = false + let errorMessage = '' + + const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ + Document, + Paragraph, + Text, + ]), { + errorOnInvalidContent: false, + onIgnoredError: error => { + errorCalled = true + errorMessage = error.message + }, + }) + + expect(errorCalled).to.eq(true) + expect(errorMessage).to.eq('[tiptap error]: Invalid JSON content') + expect(fragment.toJSON()).to.deep.eq(null) + }) + }) From 42f719468b135aff6ef4fa26788de03055c5738b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnau=20G=C3=B3mez=20Farell?= Date: Wed, 4 Jun 2025 12:57:26 +0200 Subject: [PATCH 3/4] fix: do not modify createNodeFromContent --- packages/core/src/commands/insertContentAt.ts | 24 +++- .../core/src/helpers/createNodeFromContent.ts | 29 +---- packages/core/src/types.ts | 2 +- .../core/createNodeFromContent.spec.ts | 117 ------------------ 4 files changed, 25 insertions(+), 147 deletions(-) diff --git a/packages/core/src/commands/insertContentAt.ts b/packages/core/src/commands/insertContentAt.ts index 58167f3e0..9e39fd226 100644 --- a/packages/core/src/commands/insertContentAt.ts +++ b/packages/core/src/commands/insertContentAt.ts @@ -84,14 +84,28 @@ export const insertContentAt: RawCommands['insertContentAt'] = (position, value, }) } + const parseOptions: ParseOptions = { + preserveWhitespace: 'full', + ...options.parseOptions, + } + + // If `emitContentError` is enabled, we want to check the content for errors + // but ignore them (do not remove the invalid content from the document) + if (!options.errorOnInvalidContent && editor.options.enableContentCheck && editor.options.emitContentError) { + try { + createNodeFromContent(value, editor.schema, { + parseOptions, + errorOnInvalidContent: true, + }) + } catch (e) { + emitContentError(e as Error) + } + } + try { content = createNodeFromContent(value, editor.schema, { - parseOptions: { - preserveWhitespace: 'full', - ...options.parseOptions, - }, + parseOptions, errorOnInvalidContent: options.errorOnInvalidContent ?? editor.options.enableContentCheck, - onIgnoredError: editor.options.emitContentError ? emitContentError : undefined, }) } catch (e) { emitContentError(e as Error) diff --git a/packages/core/src/helpers/createNodeFromContent.ts b/packages/core/src/helpers/createNodeFromContent.ts index dd3117f5d..5493b362c 100644 --- a/packages/core/src/helpers/createNodeFromContent.ts +++ b/packages/core/src/helpers/createNodeFromContent.ts @@ -13,13 +13,6 @@ export type CreateNodeFromContentOptions = { slice?: boolean parseOptions?: ParseOptions errorOnInvalidContent?: boolean - /** - * Runs if a content is invalid and an error would have been thrown, but - * `errorOnInvalidContent` is `false` so the invalid content is ignored. - * - * @param error The error that was not thrown - */ - onIgnoredError?: (error: Error) => void } /** @@ -63,17 +56,12 @@ export function createNodeFromContent( return node } catch (error) { - const thrownError = new Error('[tiptap error]: Invalid JSON content', { cause: error as Error }) - if (options.errorOnInvalidContent) { - throw thrownError - } - if (options.onIgnoredError) { - options.onIgnoredError(thrownError) - } else { - console.warn('[tiptap warn]: Invalid content.', 'Passed value:', content, 'Error:', error) + throw new Error('[tiptap error]: Invalid JSON content', { cause: error as Error }) } + console.warn('[tiptap warn]: Invalid content.', 'Passed value:', content, 'Error:', error) + return createNodeFromContent('', schema, options) } } @@ -117,15 +105,8 @@ export function createNodeFromContent( DOMParser.fromSchema(contentCheckSchema).parse(elementFromString(content), options.parseOptions) } - if (hasInvalidContent) { - const thrownError = new Error('[tiptap error]: Invalid HTML content', { cause: new Error(`Invalid element found: ${invalidContent}`) }) - - if (options.errorOnInvalidContent) { - throw thrownError - } else if (options.onIgnoredError) { - options.onIgnoredError(thrownError) - } - + if (options.errorOnInvalidContent && hasInvalidContent) { + throw new Error('[tiptap error]: Invalid HTML content', { cause: new Error(`Invalid element found: ${invalidContent}`) }) } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bdf399132..f01018502 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -128,7 +128,7 @@ export interface EditorOptions { */ enableContentCheck: boolean; /** - * If `true`, the editor will emit the `contentError` event invalid content is + * If `true`, the editor will emit the `contentError` event if invalid content is * encountered but `enableContentCheck` is `false`. This lets you preserve the * invalid editor content while still showing a warning or error message to * the user. diff --git a/tests/cypress/integration/core/createNodeFromContent.spec.ts b/tests/cypress/integration/core/createNodeFromContent.spec.ts index 3293adb1c..b15974820 100644 --- a/tests/cypress/integration/core/createNodeFromContent.spec.ts +++ b/tests/cypress/integration/core/createNodeFromContent.spec.ts @@ -263,121 +263,4 @@ describe('createNodeFromContent', () => { ]), { errorOnInvalidContent: true }) }).to.throw('[tiptap error]: Invalid JSON content') }) - - it('calls onIgnoredError when a schema does not have matching node types for JSON content', () => { - const content = { - type: 'non-existing-node-type', - content: [{ - type: 'text', - text: 'Example Text', - }], - } - - let errorCalled = false - let errorMessage = '' - - const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ - Document, - Paragraph, - Text, - ]), { - errorOnInvalidContent: false, - onIgnoredError: error => { - errorCalled = true - errorMessage = error.message - }, - }) - - expect(errorCalled).to.eq(true) - expect(errorMessage).to.eq('[tiptap error]: Invalid JSON content') - expect(fragment.toJSON()).to.deep.eq(null) - }) - - it('calls onIgnoredError when a schema does not have matching node types for HTML content', () => { - const content = 'Example Text' - - let errorCalled = false - let errorMessage = '' - - const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ - Document, - Paragraph, - Text, - ]), { - errorOnInvalidContent: false, - onIgnoredError: error => { - errorCalled = true - errorMessage = error.message - }, - }) - - expect(errorCalled).to.eq(true) - expect(errorMessage).to.eq('[tiptap error]: Invalid HTML content') - expect(fragment.toJSON()).to.deep.eq([{ type: 'text', text: 'Example Text' }]) - }) - - it('calls onIgnoredError when a schema does not have matching mark types for JSON content', () => { - const content = { - type: 'paragraph', - content: [{ - type: 'text', - text: 'Example Text', - marks: [{ - type: 'non-existing-mark-type', - }], - }], - } - - let errorCalled = false - let errorMessage = '' - - const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ - Document, - Paragraph, - Text, - ]), { - errorOnInvalidContent: false, - onIgnoredError: error => { - errorCalled = true - errorMessage = error.message - }, - }) - - expect(errorCalled).to.eq(true) - expect(errorMessage).to.eq('[tiptap error]: Invalid JSON content') - expect(fragment.toJSON()).to.deep.eq(null) - }) - - it('calls onIgnoredError when the JSON content does not follow the nesting rules of the schema', () => { - const content = { - type: 'paragraph', - content: [{ - type: 'paragraph', - content: [{ - type: 'text', - text: 'Example Text', - }], - }], - } - - let errorCalled = false - let errorMessage = '' - - const fragment = createNodeFromContent(content, getSchemaByResolvedExtensions([ - Document, - Paragraph, - Text, - ]), { - errorOnInvalidContent: false, - onIgnoredError: error => { - errorCalled = true - errorMessage = error.message - }, - }) - - expect(errorCalled).to.eq(true) - expect(errorMessage).to.eq('[tiptap error]: Invalid JSON content') - expect(fragment.toJSON()).to.deep.eq(null) - }) - }) From c096d4fef7826eb0ef0d9aeefd424d08e6167629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnau=20G=C3=B3mez=20Farell?= Date: Wed, 4 Jun 2025 13:11:21 +0200 Subject: [PATCH 4/4] Update packages/core/src/commands/insertContentAt.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/core/src/commands/insertContentAt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/commands/insertContentAt.ts b/packages/core/src/commands/insertContentAt.ts index 9e39fd226..c5aa7186f 100644 --- a/packages/core/src/commands/insertContentAt.ts +++ b/packages/core/src/commands/insertContentAt.ts @@ -91,7 +91,7 @@ export const insertContentAt: RawCommands['insertContentAt'] = (position, value, // If `emitContentError` is enabled, we want to check the content for errors // but ignore them (do not remove the invalid content from the document) - if (!options.errorOnInvalidContent && editor.options.enableContentCheck && editor.options.emitContentError) { + if (!options.errorOnInvalidContent && !editor.options.enableContentCheck && editor.options.emitContentError) { try { createNodeFromContent(value, editor.schema, { parseOptions,