From 31350303760163384113bf49d07127e886f94907 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:07:19 +0100 Subject: [PATCH] fix editor converter (#1647) --- apps/server/package.json | 2 +- .../src/collaboration/collaboration.util.ts | 3 +- .../helpers/prosemirror/html/generateHTML.ts | 38 ++++++++----- .../helpers/prosemirror/html/generateJSON.ts | 56 +++++++++++++++---- .../prosemirror/html/getHTMLFromFragment.ts | 54 ++++++++++++++++++ 5 files changed, 124 insertions(+), 29 deletions(-) create mode 100644 apps/server/src/common/helpers/prosemirror/html/getHTMLFromFragment.ts diff --git a/apps/server/package.json b/apps/server/package.json index 71865a07..0c5ea90e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -62,7 +62,7 @@ "class-validator": "^0.14.1", "cookie": "^1.0.2", "fs-extra": "^11.3.0", - "happy-dom": "^15.11.6", + "happy-dom": "^18.0.1", "jsonwebtoken": "^9.0.2", "kysely": "^0.28.2", "kysely-migration-cli": "^0.4.2", diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 37645f44..008bfa31 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -35,11 +35,10 @@ import { Subpages, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; -import { generateHTML } from '../common/helpers/prosemirror/html'; +import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; // @tiptap/html library works best for generating prosemirror json state but not HTML // see: https://github.com/ueberdosis/tiptap/issues/5352 // see:https://github.com/ueberdosis/tiptap/issues/4089 -import { generateJSON } from '@tiptap/html'; import { Node } from '@tiptap/pm/model'; export const tiptapExtensions = [ diff --git a/apps/server/src/common/helpers/prosemirror/html/generateHTML.ts b/apps/server/src/common/helpers/prosemirror/html/generateHTML.ts index 3622ed4c..52196aa2 100644 --- a/apps/server/src/common/helpers/prosemirror/html/generateHTML.ts +++ b/apps/server/src/common/helpers/prosemirror/html/generateHTML.ts @@ -1,21 +1,29 @@ -import { Extensions, getSchema, JSONContent } from '@tiptap/core'; -import { DOMSerializer, Node } from '@tiptap/pm/model'; -import { Window } from 'happy-dom'; +import { type Extensions, type JSONContent, getSchema } from '@tiptap/core'; +import { Node } from '@tiptap/pm/model'; +import { getHTMLFromFragment } from './getHTMLFromFragment'; +/** + * This function generates HTML from a ProseMirror JSON content object. + * + * @remarks **Important**: This function requires `happy-dom` to be installed in your project. + * @param doc - The ProseMirror JSON content object. + * @param extensions - The Tiptap extensions used to build the schema. + * @returns The generated HTML string. + * @example + * ```js + * const html = generateHTML(doc, extensions) + * console.log(html) + * ``` + */ export function generateHTML(doc: JSONContent, extensions: Extensions): string { + if (typeof window !== 'undefined') { + throw new Error( + 'generateHTML can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.', + ); + } + const schema = getSchema(extensions); const contentNode = Node.fromJSON(schema, doc); - const window = new Window(); - - const fragment = DOMSerializer.fromSchema(schema).serializeFragment( - contentNode.content, - { - document: window.document as unknown as Document, - }, - ); - - const serializer = new window.XMLSerializer(); - // @ts-ignore - return serializer.serializeToString(fragment as unknown as Node); + return getHTMLFromFragment(contentNode, schema); } diff --git a/apps/server/src/common/helpers/prosemirror/html/generateJSON.ts b/apps/server/src/common/helpers/prosemirror/html/generateJSON.ts index 23d66119..bd6e735c 100644 --- a/apps/server/src/common/helpers/prosemirror/html/generateJSON.ts +++ b/apps/server/src/common/helpers/prosemirror/html/generateJSON.ts @@ -1,21 +1,55 @@ -import { Extensions, getSchema } from '@tiptap/core'; -import { DOMParser, ParseOptions } from '@tiptap/pm/model'; +import type { Extensions } from '@tiptap/core'; +import { getSchema } from '@tiptap/core'; +import { type ParseOptions, DOMParser as PMDOMParser } from '@tiptap/pm/model'; import { Window } from 'happy-dom'; -// this function does not work as intended -// it has issues with closing tags +/** + * Generates a JSON object from the given HTML string and converts it into a Prosemirror node with content. + * @remarks **Important**: This function requires `happy-dom` to be installed in your project. + * @param {string} html - The HTML string to be converted into a Prosemirror node. + * @param {Extensions} extensions - The extensions to be used for generating the schema. + * @param {ParseOptions} options - The options to be supplied to the parser. + * @returns {Promise>} - A promise with the generated JSON object. + * @example + * const html = '

Hello, world!

' + * const extensions = [...] + * const json = generateJSON(html, extensions) + * console.log(json) // { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello, world!' }] }] } + */ export function generateJSON( html: string, extensions: Extensions, options?: ParseOptions, ): Record { - const schema = getSchema(extensions); + if (typeof window !== 'undefined') { + throw new Error( + 'generateJSON can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.', + ); + } - const window = new Window(); - const document = window.document; - document.body.innerHTML = html; + const localWindow = new Window(); + const localDOMParser = new localWindow.DOMParser(); + let result: Record; - return DOMParser.fromSchema(schema) - .parse(document as never, options) - .toJSON(); + try { + const schema = getSchema(extensions); + let doc: ReturnType | null = null; + + const htmlString = `${html}`; + doc = localDOMParser.parseFromString(htmlString, 'text/html'); + + if (!doc) { + throw new Error('Failed to parse HTML string'); + } + + result = PMDOMParser.fromSchema(schema) + .parse(doc.body as unknown as Node, options) + .toJSON(); + } finally { + // clean up happy-dom to avoid memory leaks + localWindow.happyDOM.abort(); + localWindow.happyDOM.close(); + } + + return result; } diff --git a/apps/server/src/common/helpers/prosemirror/html/getHTMLFromFragment.ts b/apps/server/src/common/helpers/prosemirror/html/getHTMLFromFragment.ts new file mode 100644 index 00000000..635ee6a4 --- /dev/null +++ b/apps/server/src/common/helpers/prosemirror/html/getHTMLFromFragment.ts @@ -0,0 +1,54 @@ +import type { Node, Schema } from '@tiptap/pm/model'; +import { DOMSerializer } from '@tiptap/pm/model'; +import { Window } from 'happy-dom'; + +/** + * Returns the HTML string representation of a given document node. + * + * @remarks **Important**: This function requires `happy-dom` to be installed in your project. + * @param doc - The document node to serialize. + * @param schema - The Prosemirror schema to use for serialization. + * @returns A promise containing the HTML string representation of the document fragment. + * + * @example + * ```typescript + * const html = getHTMLFromFragment(doc, schema) + * ``` + */ +export function getHTMLFromFragment( + doc: Node, + schema: Schema, + options?: { document?: Document }, +): string { + if (options?.document) { + const wrap = options.document.createElement('div'); + + DOMSerializer.fromSchema(schema).serializeFragment( + doc.content, + { document: options.document }, + wrap, + ); + return wrap.innerHTML; + } + + const localWindow = new Window(); + let result: string; + + try { + const fragment = DOMSerializer.fromSchema(schema).serializeFragment( + doc.content, + { + document: localWindow.document as unknown as Document, + }, + ); + + const serializer = new localWindow.XMLSerializer(); + result = serializer.serializeToString(fragment as any); + } finally { + // clean up happy-dom to avoid memory leaks + localWindow.happyDOM.abort(); + localWindow.happyDOM.close(); + } + + return result; +}