diff --git a/package.json b/package.json index 17b276159..7dfdee789 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@commitlint/cli": "^19.6.1", "@commitlint/config-conventional": "^19.6.0", "@cypress/webpack-preprocessor": "^5.17.1", + "@testing-library/react": "16.2.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@typescript-eslint/eslint-plugin": "^8.19.0", @@ -64,8 +65,9 @@ "lowlight": "^3.3.0", "minimist": "^1.2.8", "pkg-pr-new": "0.0.32", - "tinyglobby": "0.2.10", "prettier": "3.3.3", + "react": "^18.0.0", + "tinyglobby": "0.2.10", "ts-loader": "9.3.1", "tsup": "^8.3.5", "turbo": "2.3.3", diff --git a/packages/static-renderer/src/json/react/react.tsx b/packages/static-renderer/src/json/react/react.ts similarity index 100% rename from packages/static-renderer/src/json/react/react.tsx rename to packages/static-renderer/src/json/react/react.ts diff --git a/packages/static-renderer/src/pm/react/react.tsx b/packages/static-renderer/src/pm/react/react.ts similarity index 98% rename from packages/static-renderer/src/pm/react/react.tsx rename to packages/static-renderer/src/pm/react/react.ts index d38a483ff..1b871f786 100644 --- a/packages/static-renderer/src/pm/react/react.tsx +++ b/packages/static-renderer/src/pm/react/react.ts @@ -141,7 +141,7 @@ export function renderToReactElement({ domOutputSpecToElement: domOutputSpecToReactElement, mapDefinedTypes: { // Map a doc node to concatenated children - doc: ({ children }) => <>{children}, + doc: ({ children }) => React.createElement(React.Fragment, {}, children), // Map a text node to its text content text: ({ node }) => node.text ?? '', }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dbb52203..3e98093cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@cypress/webpack-preprocessor': specifier: ^5.17.1 version: 5.17.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(babel-loader@9.2.1(@babel/core@7.26.0)(webpack@5.97.1(esbuild@0.24.2)))(webpack@5.97.1(esbuild@0.24.2)) + '@testing-library/react': + specifier: 16.2.0 + version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ^18.3.18 version: 18.3.18 @@ -104,6 +107,9 @@ importers: prettier: specifier: 3.3.3 version: 3.3.3 + react: + specifier: ^18.0.0 + version: 18.3.1 tinyglobby: specifier: 0.2.10 version: 0.2.10 @@ -2360,6 +2366,28 @@ packages: svelte: ^4.0.0 || ^5.0.0-next.0 vite: ^5.0.0 + '@testing-library/dom@10.4.0': + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + + '@testing-library/react@16.2.0': + resolution: {integrity: sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/conventional-commits-parser@5.0.1': resolution: {integrity: sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==} @@ -2648,6 +2676,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} @@ -2671,6 +2703,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -3361,6 +3396,9 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@1.4.1: resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} @@ -4492,6 +4530,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -4951,6 +4993,10 @@ packages: resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} engines: {node: '>=6'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prismjs@1.29.0: resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} engines: {node: '>=6'} @@ -5069,6 +5115,9 @@ packages: peerDependencies: react: '>=16.13.1' + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.13.0: resolution: {integrity: sha512-XP8A9BT0CpRBD+NYLLeIhld/RqG9+gktUjW1FkE+Vm7OCinbG1SshcK5tb9ls4kzvjZr9mOQc7HYgBngEyPAXg==} engines: {node: '>=0.10.0'} @@ -7799,6 +7848,29 @@ snapshots: transitivePeerDependencies: - supports-color + '@testing-library/dom@10.4.0': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/runtime': 7.26.0 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/react@16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.26.0 + '@testing-library/dom': 10.4.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@types/aria-query@5.0.4': {} + '@types/conventional-commits-parser@5.0.1': dependencies: '@types/node': 22.10.3 @@ -8176,6 +8248,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.1: {} any-promise@1.3.0: {} @@ -8195,6 +8269,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -8951,6 +9029,8 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + dom-serializer@1.4.1: dependencies: domelementtype: 2.3.0 @@ -10242,6 +10322,8 @@ snapshots: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -10664,6 +10746,12 @@ snapshots: pretty-bytes@5.6.0: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prismjs@1.29.0: {} property-information@6.5.0: {} @@ -10826,6 +10914,8 @@ snapshots: '@babel/runtime': 7.26.0 react: 18.3.1 + react-is@17.0.2: {} + react-refresh@0.13.0: {} react@18.3.1: diff --git a/tests/cypress/integration/static-renderer/react-string.spec.ts b/tests/cypress/integration/static-renderer/react-string.spec.ts new file mode 100644 index 000000000..24923ca69 --- /dev/null +++ b/tests/cypress/integration/static-renderer/react-string.spec.ts @@ -0,0 +1,164 @@ +/// + +import { prettyDOM, render } from '@testing-library/react' +import Bold from '@tiptap/extension-bold' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import { Mark, Node } from '@tiptap/pm/model' +import { renderToReactElement } from '@tiptap/static-renderer/pm/react' +import React from 'react' + +describe('static render json to react elements (with prosemirror)', () => { + it('generates a React element from JSON without an editor instance', () => { + const json = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + marks: [ + { + type: 'bold', + attrs: {}, + }, + ], + }, + ], + }, + ], + attrs: {}, + } + + const view = render( + renderToReactElement({ + content: json, + extensions: [Document, Paragraph, Text, Bold], + }), + ) + const html = prettyDOM(view.container, undefined, { highlight: false }) + + expect(html).to.eq(`
+

+ + Example Text + +

+
`) + }) + + it('supports custom mapping for nodes & marks', () => { + const json = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + marks: [ + { + type: 'bold', + attrs: {}, + }, + ], + }, + ], + }, + ], + attrs: {}, + } + + const view = render( + renderToReactElement({ + content: json, + extensions: [Document, Paragraph, Text, Bold], + options: { + nodeMapping: { + doc: ({ children }) => { + return React.createElement('doc', {}, children) + }, + }, + markMapping: { + bold: ({ children }) => { + return React.createElement('b', {}, children) + }, + }, + }, + }), + ) + const html = prettyDOM(view.container, undefined, { highlight: false }) + + expect(html).to.eq(`
+ +

+ + Example Text + +

+
+
`) + }) + + it('gives access to a prosemirror node or mark instance', () => { + const json = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + marks: [ + { + type: 'bold', + attrs: {}, + }, + ], + }, + ], + }, + ], + attrs: {}, + } + + const view = render( + renderToReactElement({ + content: json, + extensions: [Document, Paragraph, Text, Bold], + options: { + nodeMapping: { + doc: ({ children, node }) => { + expect(node.type.name).to.eq('doc') + expect(node).to.be.instanceOf(Node) + return React.createElement('doc', {}, children) + }, + }, + markMapping: { + bold: ({ children, mark }) => { + expect(mark.type.name).to.eq('bold') + expect(mark).to.be.instanceOf(Mark) + return React.createElement('b', {}, children) + }, + }, + }, + }), + ) + const html = prettyDOM(view.container, undefined, { highlight: false }) + + expect(html).to.eq(`
+ +

+ + Example Text + +

+
+
`) + }) +})