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 73afd7a5..387a4350 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -34,10 +34,11 @@ import { } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML } from '../common/helpers/prosemirror/html'; +import { 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 { 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..8398b689 --- /dev/null +++ b/apps/server/src/common/helpers/prosemirror/html/getHTMLFromFragment.ts @@ -0,0 +1,43 @@ +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 +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93c56e17..08ba0316 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,7 +117,7 @@ importers: version: 3.6.1(@tiptap/core@3.6.1(@tiptap/pm@3.6.1)) '@tiptap/html': specifier: ^3.6.1 - version: 3.6.1(@tiptap/core@3.6.1(@tiptap/pm@3.6.1))(@tiptap/pm@3.6.1)(happy-dom@15.11.7) + version: 3.6.1(@tiptap/core@3.6.1(@tiptap/pm@3.6.1))(@tiptap/pm@3.6.1)(happy-dom@18.0.1) '@tiptap/pm': specifier: ^3.6.1 version: 3.6.1 @@ -508,8 +508,8 @@ importers: specifier: ^11.3.0 version: 11.3.0 happy-dom: - specifier: ^15.11.6 - version: 15.11.7 + specifier: ^18.0.1 + version: 18.0.1 jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 @@ -4531,6 +4531,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@20.19.17': + resolution: {integrity: sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==} + '@types/node@22.10.0': resolution: {integrity: sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==} @@ -4621,6 +4624,9 @@ packages: '@types/validator@13.12.0': resolution: {integrity: sha512-nH45Lk7oPIJ1RVOF6JgFI6Dy0QpHEzq4QecZhvguxYPDwT8c93prCMqAtiIttm39voZ+DDR+qkNnMpJmMBRqag==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.5.14': resolution: {integrity: sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==} @@ -6510,9 +6516,9 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} - happy-dom@15.11.7: - resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==} - engines: {node: '>=18.0.0'} + happy-dom@18.0.1: + resolution: {integrity: sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==} + engines: {node: '>=20.0.0'} has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} @@ -9441,6 +9447,9 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.10.0: resolution: {integrity: sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==} engines: {node: '>=20.18.1'} @@ -14234,11 +14243,11 @@ snapshots: '@tiptap/core': 3.6.1(@tiptap/pm@3.6.1) '@tiptap/pm': 3.6.1 - '@tiptap/html@3.6.1(@tiptap/core@3.6.1(@tiptap/pm@3.6.1))(@tiptap/pm@3.6.1)(happy-dom@15.11.7)': + '@tiptap/html@3.6.1(@tiptap/core@3.6.1(@tiptap/pm@3.6.1))(@tiptap/pm@3.6.1)(happy-dom@18.0.1)': dependencies: '@tiptap/core': 3.6.1(@tiptap/pm@3.6.1) '@tiptap/pm': 3.6.1 - happy-dom: 15.11.7 + happy-dom: 18.0.1 '@tiptap/pm@3.6.1': dependencies: @@ -14626,6 +14635,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@20.19.17': + dependencies: + undici-types: 6.21.0 + '@types/node@22.10.0': dependencies: undici-types: 6.20.0 @@ -14738,6 +14751,8 @@ snapshots: '@types/validator@13.12.0': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.5.14': dependencies: '@types/node': 22.13.4 @@ -17039,10 +17054,10 @@ snapshots: hachure-fill@0.5.2: {} - happy-dom@15.11.7: + happy-dom@18.0.1: dependencies: - entities: 4.5.0 - webidl-conversions: 7.0.0 + '@types/node': 20.19.17 + '@types/whatwg-mimetype': 3.0.2 whatwg-mimetype: 3.0.0 has-bigints@1.0.2: {} @@ -20458,6 +20473,8 @@ snapshots: undici-types@6.20.0: {} + undici-types@6.21.0: {} + undici@7.10.0: {} unicode-canonical-property-names-ecmascript@2.0.0: {}