From 32723bda92c357b5418bbfe7e93f5e1a202e5b07 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 8 Jan 2025 08:26:09 +0100 Subject: [PATCH] feat(html): switch from `zeed-dom` to `happy-dom-without-node` --- .changeset/quick-days-matter.md | 5 + packages/html/package.json | 2 +- packages/html/src/generateJSON.ts | 9 +- packages/html/src/getHTMLFromFragment.ts | 16 +- pnpm-lock.yaml | 76 +++--- .../integration/html/generateHTML.spec.ts | 223 +++++++++++++++++- 6 files changed, 286 insertions(+), 45 deletions(-) create mode 100644 .changeset/quick-days-matter.md diff --git a/.changeset/quick-days-matter.md b/.changeset/quick-days-matter.md new file mode 100644 index 00000000000..7293d84b280 --- /dev/null +++ b/.changeset/quick-days-matter.md @@ -0,0 +1,5 @@ +--- +"@tiptap/html": minor +--- + +Replace `zeed-dom` with `happy-dom` for broader compatibility of the HTML parser. diff --git a/packages/html/package.json b/packages/html/package.json index 0470a99d84e..c78c840d526 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -39,7 +39,7 @@ "@tiptap/pm": "^3.0.0-next.1" }, "dependencies": { - "zeed-dom": "^0.15.1" + "happy-dom-without-node": "^14.12.3" }, "repository": { "type": "git", diff --git a/packages/html/src/generateJSON.ts b/packages/html/src/generateJSON.ts index 19c75f65041..cadc3aec16f 100644 --- a/packages/html/src/generateJSON.ts +++ b/packages/html/src/generateJSON.ts @@ -1,6 +1,6 @@ import { Extensions, getSchema } from '@tiptap/core' import { DOMParser, ParseOptions } from '@tiptap/pm/model' -import { parseHTML } from 'zeed-dom' +import { DOMParser as HappyDOMParser, Window as HappyDOMWindow } from 'happy-dom-without-node' /** * Generates a JSON object from the given HTML string and converts it into a Prosemirror node with content. @@ -16,7 +16,10 @@ import { parseHTML } from 'zeed-dom' */ export function generateJSON(html: string, extensions: Extensions, options?: ParseOptions): Record { const schema = getSchema(extensions) - const dom = parseHTML(html) as unknown as Node - return DOMParser.fromSchema(schema).parse(dom, options).toJSON() + const parseInstance = window ? new window.DOMParser() : new HappyDOMParser(new HappyDOMWindow()) + + return DOMParser.fromSchema(schema) + .parse(parseInstance.parseFromString(html, 'text/html').body as Node, options) + .toJSON() } diff --git a/packages/html/src/getHTMLFromFragment.ts b/packages/html/src/getHTMLFromFragment.ts index 5d7badc6cb8..9d08ded7c71 100644 --- a/packages/html/src/getHTMLFromFragment.ts +++ b/packages/html/src/getHTMLFromFragment.ts @@ -1,5 +1,5 @@ import { DOMSerializer, Node, Schema } from '@tiptap/pm/model' -import { createHTMLDocument, VHTMLDocument } from 'zeed-dom' +import { Window } from 'happy-dom-without-node' /** * Returns the HTML string representation of a given document node. @@ -23,10 +23,14 @@ export function getHTMLFromFragment(doc: Node, schema: Schema, options?: { docum return wrap.innerHTML } - // Use zeed-dom for serialization. - const zeedDocument = DOMSerializer.fromSchema(schema).serializeFragment(doc.content, { - document: createHTMLDocument() as unknown as Document, - }) as unknown as VHTMLDocument + // Use happy-dom for serialization. + const browserWindow = window || new Window() - return zeedDocument.render() + const fragment = DOMSerializer.fromSchema(schema).serializeFragment(doc.content, { + document: browserWindow.document as unknown as Document, + }) + + const serializer = new browserWindow.XMLSerializer() + + return serializer.serializeToString(fragment as any) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77e57209798..e9adc07fece 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -439,15 +439,6 @@ importers: specifier: ^3.0.0-next.3 version: link:../core - packages/extension-line-height: - devDependencies: - '@tiptap/core': - specifier: ^3.0.0-next.3 - version: link:../core - '@tiptap/extension-text-style': - specifier: ^3.0.0-next.3 - version: link:../extension-text-style - packages/extension-link: dependencies: linkifyjs: @@ -622,9 +613,9 @@ importers: packages/html: dependencies: - zeed-dom: - specifier: ^0.15.1 - version: 0.15.1 + happy-dom-without-node: + specifier: ^14.12.3 + version: 14.12.3 devDependencies: '@tiptap/core': specifier: ^3.0.0-next.3 @@ -3113,10 +3104,6 @@ packages: resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css-what@6.1.0: - resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} - engines: {node: '>= 6'} - cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3438,10 +3425,6 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - entities@5.0.0: - resolution: {integrity: sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==} - engines: {node: '>=0.12'} - env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3931,6 +3914,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + happy-dom-without-node@14.12.3: + resolution: {integrity: sha512-1wp8+GFneT8mBjVnzancXHRuscEUH3vnb38lfCHPxuSu6OiRw1kQzHFbTGYlNCU2YXOlVIlzsS6xg9nAr7Xg6Q==} + engines: {node: '>=16.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -5561,6 +5548,10 @@ packages: tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tr46@5.0.0: + resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} + engines: {node: '>=18'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -5941,6 +5932,10 @@ packages: webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-sources@3.2.3: resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} engines: {node: '>=10.13.0'} @@ -5959,6 +5954,14 @@ packages: resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} engines: {node: '>=12'} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-url@14.1.0: + resolution: {integrity: sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==} + engines: {node: '>=18'} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -6089,10 +6092,6 @@ packages: resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} engines: {node: '>=12.20'} - zeed-dom@0.15.1: - resolution: {integrity: sha512-dtZ0aQSFyZmoJS0m06/xBN1SazUBPL5HpzlAcs/KcRW0rzadYw12deQBjeMhGKMMeGEp7bA9vmikMLaO4exBcg==} - engines: {node: '>=14.13.1'} - zod-package-json@1.0.3: resolution: {integrity: sha512-Mb6GzuRyUEl8X+6V6xzHbd4XV0au/4gOYrYP+CAfHL32uPmGswES+v2YqonZiW1NZWVA3jkssCKSU2knonm/aQ==} engines: {node: '>=20'} @@ -8638,8 +8637,6 @@ snapshots: mdn-data: 2.0.30 source-map-js: 1.2.1 - css-what@6.1.0: {} - cssesc@3.0.0: {} csstype@3.1.3: {} @@ -9019,8 +9016,6 @@ snapshots: entities@4.5.0: {} - entities@5.0.0: {} - env-paths@2.2.1: {} environment@1.1.0: {} @@ -9685,6 +9680,13 @@ snapshots: graphemer@1.4.0: {} + happy-dom-without-node@14.12.3: + dependencies: + entities: 4.5.0 + webidl-conversions: 7.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 14.1.0 + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -11406,6 +11408,10 @@ snapshots: dependencies: punycode: 2.3.1 + tr46@5.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -11760,6 +11766,8 @@ snapshots: webidl-conversions@4.0.2: {} + webidl-conversions@7.0.0: {} + webpack-sources@3.2.3: {} webpack@5.97.1(esbuild@0.24.2): @@ -11796,6 +11804,13 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + + whatwg-url@14.1.0: + dependencies: + tr46: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 @@ -11942,11 +11957,6 @@ snapshots: yocto-queue@1.1.1: {} - zeed-dom@0.15.1: - dependencies: - css-what: 6.1.0 - entities: 5.0.0 - zod-package-json@1.0.3: dependencies: zod: 3.24.1 diff --git a/tests/cypress/integration/html/generateHTML.spec.ts b/tests/cypress/integration/html/generateHTML.spec.ts index 9d83c89d7a2..8dd121f4fa9 100644 --- a/tests/cypress/integration/html/generateHTML.spec.ts +++ b/tests/cypress/integration/html/generateHTML.spec.ts @@ -3,7 +3,10 @@ import Document from '@tiptap/extension-document' import Paragraph from '@tiptap/extension-paragraph' import Text from '@tiptap/extension-text' -import { generateHTML } from '@tiptap/html' +import { TextStyle } from '@tiptap/extension-text-style' +import Youtube from '@tiptap/extension-youtube' +import { generateHTML, generateJSON } from '@tiptap/html' +import StarterKit from '@tiptap/starter-kit' describe('generateHTML', () => { it('generate HTML from JSON without an editor instance', () => { @@ -24,6 +27,222 @@ describe('generateHTML', () => { const html = generateHTML(json, [Document, Paragraph, Text]) - expect(html).to.eq('

Example Text

') + expect(html).to.eq('

Example Text

') + }) + + it('can convert from & to html', async () => { + const extensions = [Document, Paragraph, Text, Youtube] + const html = `

Tiptap now supports YouTube embeds! Awesome!

+
+ +
` + const json = generateJSON(html, extensions) + + expect(json).to.deep.equal({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Tiptap now supports YouTube embeds! Awesome!', + }, + ], + }, + { + type: 'youtube', + attrs: { + src: 'https://www.youtube.com/watch?v=cqHqLQgVCgY', + start: 0, + width: 640, + height: 480, + }, + }, + ], + }) + + expect(generateHTML(json, extensions)).to.equal( + '

Tiptap now supports YouTube embeds! Awesome!

', + ) + }) + + it('can convert from & to HTML with a complex schema', async () => { + const extensions = [StarterKit, TextStyle] + const html = ` +

+ Hi there, +

+

+ this is a basic example of Tiptap. Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists: +

+ +

+ Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block: +

+
body {
+  display: none;
+}
+

+ I know, I know, this is impressive. It’s only the tip of the iceberg though. Give it a try and click a little bit around. Don’t forget to check the other examples too. +

+
+ Wow, that’s amazing. Good work, boy! 👏 +
+ — Mom +
` + const json = generateJSON(html, extensions) + + const expected = { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { + level: 2, + }, + content: [ + { + type: 'text', + text: 'Hi there,', + }, + ], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'this is a ', + }, + { + type: 'text', + marks: [ + { + type: 'italic', + }, + ], + text: 'basic', + }, + { + type: 'text', + text: ' example of ', + }, + { + type: 'text', + marks: [ + { + type: 'bold', + }, + ], + text: 'Tiptap', + }, + { + type: 'text', + text: '. Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists:', + }, + ], + }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'That’s a bullet list with one …', + }, + ], + }, + ], + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: '… or two list items.', + }, + ], + }, + ], + }, + ], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block:', + }, + ], + }, + { + type: 'codeBlock', + attrs: { + language: 'css', + }, + content: [ + { + type: 'text', + text: 'body {\n display: none;\n}', + }, + ], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'I know, I know, this is impressive. It’s only the tip of the iceberg though. Give it a try and click a little bit around. Don’t forget to check the other examples too.', + }, + ], + }, + { + type: 'blockquote', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Wow, that’s amazing. Good work, boy! 👏 ', + }, + { + type: 'hardBreak', + }, + { + type: 'text', + text: '— Mom', + }, + ], + }, + ], + }, + ], + } + + expect(json).to.deep.equal(expected) + + expect(generateHTML(json, extensions)).to.equal( + `

Hi there,

this is a basic example of Tiptap. Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists:

Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block:

body {
+  display: none;
+}

I know, I know, this is impressive. It’s only the tip of the iceberg though. Give it a try and click a little bit around. Don’t forget to check the other examples too.

Wow, that’s amazing. Good work, boy! 👏
— Mom

`, + ) }) })