feat(html): switch from zeed-dom to happy-dom-without-node (#5984)
Some checks are pending
build / build (20) (push) Waiting to run
build / test (20, map[name:Demos/Commands spec:./demos/src/Commands/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Demos/Examples spec:./demos/src/Examples/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Demos/Experiments spec:./demos/src/Experiments/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Demos/Extensions spec:./demos/src/Extensions/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Demos/GuideContent spec:./demos/src/GuideContent/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Demos/GuideGettingStarted spec:./demos/src/GuideGettingStarted/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Demos/Marks spec:./demos/src/Marks/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Demos/Nodes spec:./demos/src/Nodes/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Integration spec:./tests/cypress/integration/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / release (20) (push) Blocked by required conditions
Publish / Release (20) (push) Waiting to run

This commit is contained in:
Nick Perez 2025-01-08 09:45:39 +01:00 committed by GitHub
parent efe6dafa57
commit bf040b9044
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 290 additions and 49 deletions

View File

@ -0,0 +1,5 @@
---
"@tiptap/html": major
---
Replace `zeed-dom` with `happy-dom-without-node` for broader compatibility of the HTML parser. The only difference you should see is that `happy-dom-without-node` will output `xmlns="http://www.w3.org/1999/xhtml"` on root elements, which makes it compliant with the HTML5 specification.

View File

@ -6,6 +6,6 @@ context('/src/GuideContent/GenerateHTML/React/', () => {
it('should render the content as an HTML string', () => {
cy.get('pre code').should('exist')
cy.get('pre code').should('contain', '<p>Example <strong>Text</strong></p>')
cy.get('pre code').should('contain', '<p xmlns="http://www.w3.org/1999/xhtml">Example <strong>Text</strong></p>')
})
})

View File

@ -6,6 +6,6 @@ context('/src/GuideContent/GenerateHTML/Vue/', () => {
it('should render the content as an HTML string', () => {
cy.get('pre code').should('exist')
cy.get('pre code').should('contain', '<p>Example <strong>Text</strong></p>')
cy.get('pre code').should('contain', '<p xmlns="http://www.w3.org/1999/xhtml">Example <strong>Text</strong></p>')
})
})

View File

@ -1,6 +1,6 @@
context('/src/GuideContent/GenerateHTML/Vue/', () => {
context('/src/GuideContent/StaticRenderHTML/Vue/', () => {
before(() => {
cy.visit('/src/GuideContent/GenerateHTML/Vue/')
cy.visit('/src/GuideContent/StaticRenderHTML/Vue/')
})
it('should render the content as an HTML string', () => {

View File

@ -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",

View File

@ -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<string, any> {
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()
}

View File

@ -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)
}

View File

@ -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

View File

@ -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('<p>Example Text</p>')
expect(html).to.eq('<p xmlns="http://www.w3.org/1999/xhtml">Example Text</p>')
})
it('can convert from & to html', async () => {
const extensions = [Document, Paragraph, Text, Youtube]
const html = `<p>Tiptap now supports YouTube embeds! Awesome!</p>
<div data-youtube-video>
<iframe src="https://www.youtube.com/watch?v=cqHqLQgVCgY"></iframe>
</div>`
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(
'<p xmlns="http://www.w3.org/1999/xhtml">Tiptap now supports YouTube embeds! Awesome!</p><div xmlns="http://www.w3.org/1999/xhtml" data-youtube-video=""><iframe width="640" height="480" allowfullscreen="true" autoplay="false" disablekbcontrols="false" enableiframeapi="false" endtime="0" ivloadpolicy="0" loop="false" modestbranding="false" origin="" playlist="" src="https://www.youtube.com/embed/cqHqLQgVCgY" start="0"></iframe></div>',
)
})
it('can convert from & to HTML with a complex schema', async () => {
const extensions = [StarterKit, TextStyle]
const html = `
<h2>
Hi there,
</h2>
<p>
this is a <em>basic</em> example of <strong>Tiptap</strong>. Sure, there are all kind of basic text styles youd probably expect from a text editor. But wait until you see the lists:
</p>
<ul>
<li>
Thats a bullet list with one
</li>
<li>
or two list items.
</li>
</ul>
<p>
Isnt that great? And all of that is editable. But wait, theres more. Lets try a code block:
</p>
<pre><code class="language-css">body {
display: none;
}</code></pre>
<p>
I know, I know, this is impressive. Its only the tip of the iceberg though. Give it a try and click a little bit around. Dont forget to check the other examples too.
</p>
<blockquote>
Wow, thats amazing. Good work, boy! 👏
<br />
Mom
</blockquote>`
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 youd 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: 'Thats a bullet list with one …',
},
],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '… or two list items.',
},
],
},
],
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Isnt that great? And all of that is editable. But wait, theres more. Lets 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. Its only the tip of the iceberg though. Give it a try and click a little bit around. Dont forget to check the other examples too.',
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Wow, thats amazing. Good work, boy! 👏 ',
},
{
type: 'hardBreak',
},
{
type: 'text',
text: '— Mom',
},
],
},
],
},
],
}
expect(json).to.deep.equal(expected)
expect(generateHTML(json, extensions)).to.equal(
`<h2 xmlns="http://www.w3.org/1999/xhtml">Hi there,</h2><p xmlns="http://www.w3.org/1999/xhtml">this is a <em>basic</em> example of <strong>Tiptap</strong>. Sure, there are all kind of basic text styles youd probably expect from a text editor. But wait until you see the lists:</p><ul xmlns="http://www.w3.org/1999/xhtml"><li><p>Thats a bullet list with one …</p></li><li><p>… or two list items.</p></li></ul><p xmlns="http://www.w3.org/1999/xhtml">Isnt that great? And all of that is editable. But wait, theres more. Lets try a code block:</p><pre xmlns="http://www.w3.org/1999/xhtml"><code class="language-css">body {
display: none;
}</code></pre><p xmlns="http://www.w3.org/1999/xhtml">I know, I know, this is impressive. Its only the tip of the iceberg though. Give it a try and click a little bit around. Dont forget to check the other examples too.</p><blockquote xmlns="http://www.w3.org/1999/xhtml"><p>Wow, thats amazing. Good work, boy! 👏 <br /> Mom</p></blockquote>`,
)
})
})