snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
This commit is contained in:
1917
packages/static-renderer/CHANGELOG.md
Normal file
1917
packages/static-renderer/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
51
packages/static-renderer/README.md
Normal file
51
packages/static-renderer/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# @tiptap/static-renderer
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/static-renderer)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/static-renderer)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
## Introduction
|
||||
|
||||
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
|
||||
|
||||
## Official Documentation
|
||||
|
||||
Documentation can be found on the [Tiptap website](https://tiptap.dev).
|
||||
|
||||
## Limitations & workarounds
|
||||
|
||||
The static renderer builds the ProseMirror schema and runs each extension's
|
||||
`renderHTML`, but it does **not** instantiate an `Editor`. As a result:
|
||||
|
||||
- `addProseMirrorPlugins`, `onCreate`, `onUpdate`, and transaction hooks do
|
||||
not run.
|
||||
- Extensions that assign attributes via those mechanisms — such as
|
||||
`UniqueID` (`data-id`) and `TableOfContents` (`id`, `data-toc-id`) — will
|
||||
not populate those attributes on their own.
|
||||
|
||||
For these cases, pre-process the JSON document before rendering:
|
||||
|
||||
```ts
|
||||
import { generateUniqueIds } from '@tiptap/extension-unique-id'
|
||||
import { generateTocIds } from '@tiptap/extension-table-of-contents'
|
||||
import { renderToHTMLString } from '@tiptap/static-renderer/pm/html-string'
|
||||
|
||||
let doc = sourceJson
|
||||
doc = generateUniqueIds(doc, extensions) // if using UniqueID
|
||||
doc = generateTocIds(doc, extensions) // if using TableOfContents
|
||||
|
||||
const html = renderToHTMLString({
|
||||
content: doc,
|
||||
extensions,
|
||||
staticEditorOptions: { textDirection: 'auto' }, // mirrors a subset of EditorOptions
|
||||
})
|
||||
```
|
||||
|
||||
Editor-level options that affect output are accepted via the
|
||||
`staticEditorOptions` object (currently `textDirection`). Other editor options
|
||||
that depend on a runtime view or transaction stream are out of scope.
|
||||
|
||||
## License
|
||||
|
||||
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).
|
||||
99
packages/static-renderer/package.json
Normal file
99
packages/static-renderer/package.json
Normal file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"name": "@tiptap/static-renderer",
|
||||
"version": "3.30.3",
|
||||
"description": "statically render Tiptap JSON",
|
||||
"keywords": [
|
||||
"tiptap",
|
||||
"tiptap react renderer",
|
||||
"tiptap static renderer"
|
||||
],
|
||||
"homepage": "https://tiptap.dev",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ueberdosis/tiptap/issues"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ueberdosis/tiptap",
|
||||
"directory": "packages/static-renderer"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": {
|
||||
"import": "./dist/index.d.ts",
|
||||
"require": "./dist/index.d.cts"
|
||||
},
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./json/react": {
|
||||
"types": {
|
||||
"import": "./dist/json/react/index.d.ts",
|
||||
"require": "./dist/json/react/index.d.cts"
|
||||
},
|
||||
"import": "./dist/json/react/index.js",
|
||||
"require": "./dist/json/react/index.cjs"
|
||||
},
|
||||
"./json/html-string": {
|
||||
"types": {
|
||||
"import": "./dist/json/html-string/index.d.ts",
|
||||
"require": "./dist/json/html-string/index.d.cts"
|
||||
},
|
||||
"import": "./dist/json/html-string/index.js",
|
||||
"require": "./dist/json/html-string/index.cjs"
|
||||
},
|
||||
"./pm/react": {
|
||||
"types": {
|
||||
"import": "./dist/pm/react/index.d.ts",
|
||||
"require": "./dist/pm/react/index.d.cts"
|
||||
},
|
||||
"import": "./dist/pm/react/index.js",
|
||||
"require": "./dist/pm/react/index.cjs"
|
||||
},
|
||||
"./pm/html-string": {
|
||||
"types": {
|
||||
"import": "./dist/pm/html-string/index.d.ts",
|
||||
"require": "./dist/pm/html-string/index.d.cts"
|
||||
},
|
||||
"import": "./dist/pm/html-string/index.js",
|
||||
"require": "./dist/pm/html-string/index.cjs"
|
||||
},
|
||||
"./pm/markdown": {
|
||||
"types": {
|
||||
"import": "./dist/pm/markdown/index.d.ts",
|
||||
"require": "./dist/pm/markdown/index.d.cts"
|
||||
},
|
||||
"import": "./dist/pm/markdown/index.js",
|
||||
"require": "./dist/pm/markdown/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp pack"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "workspace:^",
|
||||
"@tiptap/pm": "workspace:^",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "workspace:*",
|
||||
"@tiptap/pm": "workspace:*",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
}
|
||||
70
packages/static-renderer/src/helpers.ts
Normal file
70
packages/static-renderer/src/helpers.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/* oslint-disableno-explicit-any */
|
||||
import {
|
||||
type ExtensionAttribute,
|
||||
type MarkType,
|
||||
type NodeType,
|
||||
mergeAttributes,
|
||||
} from '@tiptap/core'
|
||||
|
||||
/**
|
||||
* This function returns the attributes of a node or mark that are defined by the given extension attributes.
|
||||
* @param nodeOrMark The node or mark to get the attributes from
|
||||
* @param extensionAttributes The extension attributes to use
|
||||
* @param onlyRenderedAttributes If true, only attributes that are rendered in the HTML are returned
|
||||
*/
|
||||
export function getAttributes(
|
||||
nodeOrMark: NodeType | MarkType,
|
||||
extensionAttributes: ExtensionAttribute[],
|
||||
onlyRenderedAttributes?: boolean,
|
||||
): Record<string, any> {
|
||||
const nodeOrMarkAttributes = nodeOrMark.attrs
|
||||
|
||||
if (!nodeOrMarkAttributes) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return extensionAttributes
|
||||
.filter(item => {
|
||||
if (
|
||||
item.type !== (typeof nodeOrMark.type === 'string' ? nodeOrMark.type : nodeOrMark.type.name)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (onlyRenderedAttributes) {
|
||||
return item.attribute.rendered
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(item => {
|
||||
if (!item.attribute.renderHTML) {
|
||||
return {
|
||||
[item.name]:
|
||||
item.name in nodeOrMarkAttributes
|
||||
? nodeOrMarkAttributes[item.name]
|
||||
: item.attribute.default,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
item.attribute.renderHTML(nodeOrMarkAttributes) || {
|
||||
[item.name]:
|
||||
item.name in nodeOrMarkAttributes
|
||||
? nodeOrMarkAttributes[item.name]
|
||||
: item.attribute.default,
|
||||
}
|
||||
)
|
||||
})
|
||||
.reduce((attributes, attribute) => mergeAttributes(attributes, attribute), {})
|
||||
}
|
||||
|
||||
/**
|
||||
* This function returns the HTML attributes of a node or mark that are defined by the given extension attributes.
|
||||
* @param nodeOrMark The node or mark to get the attributes from
|
||||
* @param extensionAttributes The extension attributes to use
|
||||
*/
|
||||
export function getHTMLAttributes(
|
||||
nodeOrMark: NodeType | MarkType,
|
||||
extensionAttributes: ExtensionAttribute[],
|
||||
) {
|
||||
return getAttributes(nodeOrMark, extensionAttributes, true)
|
||||
}
|
||||
6
packages/static-renderer/src/index.ts
Normal file
6
packages/static-renderer/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './helpers.js'
|
||||
export * from './json/html-string/index.js'
|
||||
export * from './json/react/index.js'
|
||||
export * from './pm/html-string/index.js'
|
||||
export * from './pm/markdown/index.js'
|
||||
export * from './pm/react/index.js'
|
||||
2
packages/static-renderer/src/json/html-string/index.ts
Normal file
2
packages/static-renderer/src/json/html-string/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from '../renderer.js'
|
||||
export * from './string.js'
|
||||
205
packages/static-renderer/src/json/html-string/string.test.ts
Normal file
205
packages/static-renderer/src/json/html-string/string.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
renderJSONContentToString,
|
||||
serializeAttrsToHTMLString,
|
||||
serializeChildrenToHTMLString,
|
||||
} from './string.js'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
describe('static render json to string (no prosemirror)', () => {
|
||||
it('generate an HTML string from JSON without an editor instance', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Example Text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
attrs: {},
|
||||
}
|
||||
|
||||
const html = renderJSONContentToString({
|
||||
nodeMapping: {
|
||||
doc: ({ children }) => {
|
||||
return `<doc>${serializeChildrenToHTMLString(children)}</doc>`
|
||||
},
|
||||
paragraph: ({ children }) => {
|
||||
return `<p>${serializeChildrenToHTMLString(children)}</p>`
|
||||
},
|
||||
text: ({ node }) => {
|
||||
// `node.text` is accessible directly (typed `string | undefined`)
|
||||
// without casting to `TextType`.
|
||||
return node.text ?? ''
|
||||
},
|
||||
},
|
||||
markMapping: {},
|
||||
})({ content: json })
|
||||
|
||||
expect(html).toBe('<doc><p>Example Text</p></doc>')
|
||||
})
|
||||
|
||||
it('supports mapping nodes & marks', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Example Text',
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
attrs: {},
|
||||
}
|
||||
|
||||
const html = renderJSONContentToString({
|
||||
nodeMapping: {
|
||||
doc: ({ children }) => {
|
||||
return `<doc>${serializeChildrenToHTMLString(children)}</doc>`
|
||||
},
|
||||
paragraph: ({ children }) => {
|
||||
return `<p>${serializeChildrenToHTMLString(children)}</p>`
|
||||
},
|
||||
text: ({ node }) => {
|
||||
// `node.text` is accessible directly (typed `string | undefined`)
|
||||
// without casting to `TextType`.
|
||||
return node.text ?? ''
|
||||
},
|
||||
},
|
||||
markMapping: {
|
||||
bold: ({ children }) => {
|
||||
return `<strong>${serializeChildrenToHTMLString(children)}</strong>`
|
||||
},
|
||||
},
|
||||
})({ content: json })
|
||||
|
||||
expect(html).toBe('<doc><p><strong>Example Text</strong></p></doc>')
|
||||
})
|
||||
|
||||
it('escapes serialized HTML attributes', () => {
|
||||
const attrs = serializeAttrsToHTMLString({
|
||||
href: 'x"><img src=x onerror=alert(document.cookie)>',
|
||||
title: 'Tom & "Jerry"',
|
||||
})
|
||||
|
||||
expect(attrs).toBe(
|
||||
' href="x"><img src=x onerror=alert(document.cookie)>" title="Tom & "Jerry""',
|
||||
)
|
||||
})
|
||||
|
||||
it('gives access to the original JSON node or mark', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: {
|
||||
level: 2,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Example Text',
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
attrs: {},
|
||||
}
|
||||
|
||||
const html = renderJSONContentToString({
|
||||
nodeMapping: {
|
||||
doc: ({ node, children }) => {
|
||||
expect(node).toEqual(json)
|
||||
return `<doc>${serializeChildrenToHTMLString(children)}</doc>`
|
||||
},
|
||||
heading: ({ node, children }) => {
|
||||
expect(node).toEqual({
|
||||
type: 'heading',
|
||||
attrs: {
|
||||
level: 2,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Example Text',
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
return `<h${node.attrs.level}>${serializeChildrenToHTMLString(children)}</h${node.attrs.level}>`
|
||||
},
|
||||
text: ({ node }) => {
|
||||
expect(node).toEqual({
|
||||
type: 'text',
|
||||
text: 'Example Text',
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
})
|
||||
// `node.text` is accessible directly (typed `string | undefined`)
|
||||
// without casting to `TextType`.
|
||||
return node.text ?? ''
|
||||
},
|
||||
},
|
||||
markMapping: {
|
||||
bold: ({ children, mark }) => {
|
||||
expect(mark).toEqual({
|
||||
type: 'bold',
|
||||
attrs: {},
|
||||
})
|
||||
return `<strong>${serializeChildrenToHTMLString(children)}</strong>`
|
||||
},
|
||||
},
|
||||
})({ content: json })
|
||||
|
||||
expect(html).toBe('<doc><h2><strong>Example Text</strong></h2></doc>')
|
||||
})
|
||||
|
||||
it('throws a clear "missing handler" error for a node without a type instead of crashing', () => {
|
||||
// Node-type resolution falls back to '' for a missing/undefined `type`, so a
|
||||
// malformed node routes through the normal "missing handler" contract rather
|
||||
// than throwing a raw TypeError on `content.type.name`.
|
||||
const render = renderJSONContentToString({ nodeMapping: {}, markMapping: {} })
|
||||
|
||||
expect(() => render({ content: {} })).toThrow(/missing handler for node type/)
|
||||
})
|
||||
|
||||
it('routes a node without a type to unhandledNode when one is provided', () => {
|
||||
const html = renderJSONContentToString({
|
||||
nodeMapping: {},
|
||||
markMapping: {},
|
||||
unhandledNode: () => '<unhandled />',
|
||||
})({ content: {} })
|
||||
|
||||
expect(html).toBe('<unhandled />')
|
||||
})
|
||||
})
|
||||
68
packages/static-renderer/src/json/html-string/string.ts
Normal file
68
packages/static-renderer/src/json/html-string/string.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/* oslint-disableno-explicit-any */
|
||||
import type { JSONMarkType, JSONNodeType, TiptapStaticRendererOptions } from '../renderer.js'
|
||||
import { TiptapStaticRenderer } from '../renderer.js'
|
||||
|
||||
export function renderJSONContentToString<
|
||||
/**
|
||||
* A mark type is either a JSON representation of a mark or a Prosemirror mark instance
|
||||
*/
|
||||
TMarkType extends { type: any } = JSONMarkType,
|
||||
/**
|
||||
* A node type is either a JSON representation of a node or a Prosemirror node instance
|
||||
*/
|
||||
TNodeType extends {
|
||||
content?: { forEach: (cb: (node: TNodeType) => void) => void }
|
||||
marks?: readonly TMarkType[]
|
||||
type?: string | { name: string }
|
||||
} = JSONNodeType<TMarkType>,
|
||||
>(options: TiptapStaticRendererOptions<string, TMarkType, TNodeType>) {
|
||||
return TiptapStaticRenderer(ctx => {
|
||||
return ctx.component(ctx.props as any)
|
||||
}, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape text for HTML text content.
|
||||
* @param value The text to escape
|
||||
* @returns The escaped text
|
||||
*/
|
||||
export function escapeHTML(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape values for quoted HTML attributes.
|
||||
* @param value The attribute value to escape
|
||||
* @returns The escaped attribute value
|
||||
*/
|
||||
export function escapeHTMLAttribute(value: string): string {
|
||||
return escapeHTML(value).replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the attributes of a node or mark to a string
|
||||
* @param attrs The attributes to serialize
|
||||
* @returns The serialized attributes as a string
|
||||
*/
|
||||
export function serializeAttrsToHTMLString(attrs: Record<string, any> | undefined | null): string {
|
||||
// Match ProseMirror's DOMSerializer.renderSpec, which omits null/undefined attribute
|
||||
// values rather than stringifying them — otherwise we emit attrs like class="null".
|
||||
const output = Object.entries(attrs || {})
|
||||
.filter(([, value]) => value != null)
|
||||
.map(([key, value]) => `${key.split(' ').at(-1)}="${escapeHTMLAttribute(String(value))}"`)
|
||||
.join(' ')
|
||||
|
||||
return output ? ` ${output}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the children of a node or mark to a string
|
||||
* @param children The children to serialize
|
||||
* @returns The serialized children as a string
|
||||
*/
|
||||
export function serializeChildrenToHTMLString(children?: string | string[]): string {
|
||||
return ([] as string[])
|
||||
.concat(children || '')
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
}
|
||||
2
packages/static-renderer/src/json/react/index.ts
Normal file
2
packages/static-renderer/src/json/react/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from '../renderer.js'
|
||||
export * from './react.js'
|
||||
35
packages/static-renderer/src/json/react/react.ts
Normal file
35
packages/static-renderer/src/json/react/react.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/* oslint-disableno-explicit-any */
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import type { JSONMarkType, JSONNodeType, TiptapStaticRendererOptions } from '../renderer.js'
|
||||
import { TiptapStaticRenderer } from '../renderer.js'
|
||||
|
||||
export function renderJSONContentToReactElement<
|
||||
/**
|
||||
* A mark type is either a JSON representation of a mark or a Prosemirror mark instance
|
||||
*/
|
||||
TMarkType extends { type: any } = JSONMarkType,
|
||||
/**
|
||||
* A node type is either a JSON representation of a node or a Prosemirror node instance
|
||||
*/
|
||||
TNodeType extends {
|
||||
content?: { forEach: (cb: (node: TNodeType) => void) => void }
|
||||
marks?: readonly TMarkType[]
|
||||
type?: string | { name: string }
|
||||
} = JSONNodeType<TMarkType>,
|
||||
>(options: TiptapStaticRendererOptions<React.ReactNode, TMarkType, TNodeType>) {
|
||||
let key = 0
|
||||
|
||||
return TiptapStaticRenderer<React.ReactNode, TMarkType, TNodeType>(
|
||||
({ component, props: { children, ...props } }) => {
|
||||
return React.createElement(
|
||||
component as React.FC<typeof props>,
|
||||
// oxlint-disable-next-line no-plusplus
|
||||
Object.assign(props, { key: key++ }),
|
||||
([] as React.ReactNode[]).concat(children),
|
||||
)
|
||||
},
|
||||
options,
|
||||
)
|
||||
}
|
||||
283
packages/static-renderer/src/json/renderer.ts
Normal file
283
packages/static-renderer/src/json/renderer.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/* oslint-disableno-explicit-any */
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
/**
|
||||
* A JSON representation of a mark (a Tiptap/ProseMirror mark serialized to JSON).
|
||||
*/
|
||||
export type JSONMarkType = NonNullable<JSONContent['marks']>[number]
|
||||
|
||||
/**
|
||||
* A JSON representation of a node (a Tiptap/ProseMirror node serialized to JSON).
|
||||
*
|
||||
* `marks` is tied to the `TMark` type parameter so the node<->mark relationship
|
||||
* stays sound. This is also why we cannot simply default `TNodeType` to
|
||||
* `JSONContent`: the generic constraint references `TMarkType` via
|
||||
* `marks?: readonly TMarkType[]`, and `JSONContent.marks` is a *concrete* array,
|
||||
* which TypeScript rejects ("TMarkType could be instantiated with a different
|
||||
* subtype of constraint"). Parameterizing the node type by the same `TMark`
|
||||
* avoids that bivariance error. Please don't "simplify" this back to
|
||||
* `JSONContent` — it won't type-check.
|
||||
*/
|
||||
export type JSONNodeType<TMark extends { type: string | { name: string } } = JSONMarkType> = {
|
||||
type?: string
|
||||
attrs?: Record<string, any>
|
||||
content?: JSONNodeType<TMark>[]
|
||||
marks?: readonly TMark[]
|
||||
text?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for a node renderer
|
||||
*/
|
||||
export type NodeProps<TNodeType = any, TChildren = any> = {
|
||||
/**
|
||||
* The current node to render
|
||||
*/
|
||||
node: TNodeType
|
||||
/**
|
||||
* Unless the node is the root node, this will always be defined
|
||||
*/
|
||||
parent?: TNodeType
|
||||
/**
|
||||
* The children of the current node
|
||||
*/
|
||||
children?: TChildren
|
||||
/**
|
||||
* Render a child element
|
||||
*/
|
||||
renderElement: (props: {
|
||||
/**
|
||||
* Tiptap JSON content to render
|
||||
*/
|
||||
content: TNodeType
|
||||
/**
|
||||
* The parent node of the current node
|
||||
*/
|
||||
parent?: TNodeType
|
||||
}) => TChildren
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for a mark renderer
|
||||
*/
|
||||
export type MarkProps<TMarkType = any, TChildren = any, TNodeType = any> = {
|
||||
/**
|
||||
* The current mark to render
|
||||
*/
|
||||
mark: TMarkType
|
||||
/**
|
||||
* The children of the current mark
|
||||
*/
|
||||
children?: TChildren
|
||||
/**
|
||||
* The node the current mark is applied to
|
||||
*/
|
||||
node: TNodeType
|
||||
/**
|
||||
* The node the current mark is applied to
|
||||
*/
|
||||
parent?: TNodeType
|
||||
}
|
||||
|
||||
export type TiptapStaticRendererOptions<
|
||||
/**
|
||||
* The return type of the render function (e.g. React.ReactNode, string)
|
||||
*/
|
||||
TReturnType,
|
||||
/**
|
||||
* A mark type is either a JSON representation of a mark or a Prosemirror mark instance
|
||||
*/
|
||||
TMarkType extends { type: any } = JSONMarkType,
|
||||
/**
|
||||
* A node type is either a JSON representation of a node or a Prosemirror node instance
|
||||
*/
|
||||
TNodeType extends {
|
||||
content?: { forEach: (cb: (node: TNodeType) => void) => void }
|
||||
marks?: readonly TMarkType[]
|
||||
type?: string | { name: string }
|
||||
} = JSONNodeType<TMarkType>,
|
||||
/**
|
||||
* A node renderer is a function that takes a node and its children and returns the rendered output
|
||||
*/
|
||||
TNodeRender extends (ctx: NodeProps<TNodeType, TReturnType | TReturnType[]>) => TReturnType = (
|
||||
ctx: NodeProps<TNodeType, TReturnType | TReturnType[]>,
|
||||
) => TReturnType,
|
||||
/**
|
||||
* A mark renderer is a function that takes a mark and its children and returns the rendered output
|
||||
*/
|
||||
TMarkRender extends (
|
||||
ctx: MarkProps<TMarkType, TReturnType | TReturnType[], TNodeType>,
|
||||
) => TReturnType = (
|
||||
ctx: MarkProps<TMarkType, TReturnType | TReturnType[], TNodeType>,
|
||||
) => TReturnType,
|
||||
> = {
|
||||
/**
|
||||
* Mapping of node types to react components
|
||||
*/
|
||||
nodeMapping: Record<string, NoInfer<TNodeRender>>
|
||||
/**
|
||||
* Mapping of mark types to react components
|
||||
*/
|
||||
markMapping: Record<string, NoInfer<TMarkRender>>
|
||||
/**
|
||||
* Component to render if a node type is not handled.
|
||||
*
|
||||
* With the ProseMirror renderers, a type missing from the schema is passed here
|
||||
* as a lightweight stand-in: `node.type` is only `{ name }` (no real `NodeType`
|
||||
* exists for a type absent from the schema), while `node.attrs` and
|
||||
* `node.toJSON()` reflect the original. Do not rely on `type.spec`,
|
||||
* `type.schema`, `isInline`, etc.
|
||||
*/
|
||||
unhandledNode?: NoInfer<TNodeRender>
|
||||
/**
|
||||
* Component to render if a mark type is not handled.
|
||||
*
|
||||
* With the ProseMirror renderers, a type missing from the schema is passed here
|
||||
* as a lightweight stand-in: `mark.type` is only `{ name }` (no real `MarkType`
|
||||
* exists for a type absent from the schema), while `mark.attrs` and
|
||||
* `mark.toJSON()` reflect the original. Do not rely on `type.spec`,
|
||||
* `type.schema`, etc.
|
||||
*/
|
||||
unhandledMark?: NoInfer<TMarkRender>
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiptap Static Renderer
|
||||
* ----------------------
|
||||
*
|
||||
* This function is a basis to allow for different renderers to be created.
|
||||
* Generic enough to be able to statically render Prosemirror JSON or Prosemirror Nodes.
|
||||
*
|
||||
* Using this function, you can create a renderer that takes a JSON representation of a Prosemirror document
|
||||
* and renders it using a mapping of node types to React components or even to a string.
|
||||
* This function is used as the basis to create the `reactRenderer` and `stringRenderer` functions.
|
||||
*/
|
||||
export function TiptapStaticRenderer<
|
||||
/**
|
||||
* The return type of the render function (e.g. React.ReactNode, string)
|
||||
*/
|
||||
TReturnType,
|
||||
/**
|
||||
* A mark type is either a JSON representation of a mark or a Prosemirror mark instance
|
||||
*/
|
||||
TMarkType extends { type: string | { name: string } } = JSONMarkType,
|
||||
/**
|
||||
* A node type is either a JSON representation of a node or a Prosemirror node instance
|
||||
*/
|
||||
TNodeType extends {
|
||||
content?: { forEach: (cb: (node: TNodeType) => void) => void }
|
||||
marks?: readonly TMarkType[]
|
||||
type?: string | { name: string }
|
||||
} = JSONNodeType<TMarkType>,
|
||||
/**
|
||||
* A node renderer is a function that takes a node and its children and returns the rendered output
|
||||
*/
|
||||
TNodeRender extends (ctx: NodeProps<TNodeType, TReturnType | TReturnType[]>) => TReturnType = (
|
||||
ctx: NodeProps<TNodeType, TReturnType | TReturnType[]>,
|
||||
) => TReturnType,
|
||||
/**
|
||||
* A mark renderer is a function that takes a mark and its children and returns the rendered output
|
||||
*/
|
||||
TMarkRender extends (
|
||||
ctx: MarkProps<TMarkType, TReturnType | TReturnType[], TNodeType>,
|
||||
) => TReturnType = (
|
||||
ctx: MarkProps<TMarkType, TReturnType | TReturnType[], TNodeType>,
|
||||
) => TReturnType,
|
||||
>(
|
||||
/**
|
||||
* The function that actually renders the component
|
||||
*/
|
||||
renderComponent: (
|
||||
ctx:
|
||||
| {
|
||||
component: TNodeRender
|
||||
props: NodeProps<TNodeType, TReturnType | TReturnType[]>
|
||||
}
|
||||
| {
|
||||
component: TMarkRender
|
||||
props: MarkProps<TMarkType, TReturnType | TReturnType[], TNodeType>
|
||||
},
|
||||
) => TReturnType,
|
||||
{
|
||||
nodeMapping,
|
||||
markMapping,
|
||||
unhandledNode,
|
||||
unhandledMark,
|
||||
}: TiptapStaticRendererOptions<TReturnType, TMarkType, TNodeType, TNodeRender, TMarkRender>,
|
||||
) {
|
||||
/**
|
||||
* Render Tiptap JSON and all its children using the provided node and mark mappings.
|
||||
*/
|
||||
return function renderContent({
|
||||
content,
|
||||
parent,
|
||||
}: {
|
||||
/**
|
||||
* Tiptap JSON content to render
|
||||
*/
|
||||
content: TNodeType
|
||||
/**
|
||||
* The parent node of the current node
|
||||
*/
|
||||
parent?: TNodeType
|
||||
}): TReturnType {
|
||||
const nodeType = typeof content.type === 'string' ? content.type : (content.type?.name ?? '')
|
||||
const NodeHandler = nodeMapping[nodeType] ?? unhandledNode
|
||||
|
||||
if (!NodeHandler) {
|
||||
throw new Error(`missing handler for node type ${nodeType}`)
|
||||
}
|
||||
|
||||
const nodeContent = renderComponent({
|
||||
component: NodeHandler,
|
||||
props: {
|
||||
node: content,
|
||||
parent,
|
||||
renderElement: renderContent,
|
||||
// Lazily compute the children to avoid unnecessary recursion
|
||||
get children() {
|
||||
// recursively render child content nodes
|
||||
const children: TReturnType[] = []
|
||||
|
||||
if (content.content) {
|
||||
content.content.forEach(child => {
|
||||
children.push(
|
||||
renderContent({
|
||||
content: child,
|
||||
parent: content,
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return children
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// apply marks to the content
|
||||
const markedContent = content.marks
|
||||
? content.marks.reduce((acc, mark) => {
|
||||
const markType = typeof mark.type === 'string' ? mark.type : mark.type.name
|
||||
const MarkHandler = markMapping[markType] ?? unhandledMark
|
||||
|
||||
if (!MarkHandler) {
|
||||
throw new Error(`missing handler for mark type ${markType}`)
|
||||
}
|
||||
|
||||
return renderComponent({
|
||||
component: MarkHandler,
|
||||
props: {
|
||||
mark,
|
||||
parent,
|
||||
node: content,
|
||||
children: acc,
|
||||
},
|
||||
})
|
||||
}, nodeContent)
|
||||
: nodeContent
|
||||
|
||||
return markedContent
|
||||
}
|
||||
}
|
||||
481
packages/static-renderer/src/pm/extensionRenderer.ts
Normal file
481
packages/static-renderer/src/pm/extensionRenderer.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
/* oslint-disable no-plusplus */
|
||||
/* oslint-disableno-explicit-any */
|
||||
|
||||
import type {
|
||||
ExtensionAttribute,
|
||||
Extensions,
|
||||
JSONContent,
|
||||
Mark as MarkExtension,
|
||||
MarkConfig,
|
||||
Node as NodeExtension,
|
||||
NodeConfig,
|
||||
} from '@tiptap/core'
|
||||
import {
|
||||
extensions as coreExtensions,
|
||||
getAttributesFromExtensions,
|
||||
getExtensionField,
|
||||
getSchemaByResolvedExtensions,
|
||||
resolveExtensions,
|
||||
splitExtensions,
|
||||
} from '@tiptap/core'
|
||||
import type { DOMOutputSpec, Mark } from '@tiptap/pm/model'
|
||||
import { Node as PMNode, Schema } from '@tiptap/pm/model'
|
||||
|
||||
import { getHTMLAttributes } from '../helpers.js'
|
||||
import type { MarkProps, NodeProps, TiptapStaticRendererOptions } from '../json/renderer.js'
|
||||
|
||||
export type DomOutputSpecToElement<T> = (content: DOMOutputSpec) => (children?: T | T[]) => T
|
||||
|
||||
/**
|
||||
* Options that mirror a subset of `EditorOptions` and affect rendered output.
|
||||
* Kept narrow on purpose: only options whose effect is reproducible without an
|
||||
* `Editor` instance belong here.
|
||||
*/
|
||||
export type StaticEditorOptions = {
|
||||
/**
|
||||
* Sets the text direction for all non-text nodes. Matches the `textDirection`
|
||||
* editor option on `Editor`. The configured `TextDirection` extension is
|
||||
* prepended to the user-supplied `extensions`; if a user-supplied
|
||||
* `TextDirection` is also present, the user's wins (last-defined precedence —
|
||||
* same as Editor).
|
||||
*/
|
||||
textDirection?: 'ltr' | 'rtl' | 'auto'
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply editor-level options to the user's extension array.
|
||||
*
|
||||
* Mirrors `new Editor({ textDirection })`: the option-driven `TextDirection`
|
||||
* extension is prepended so a user-supplied `TextDirection` (which comes after)
|
||||
* can override it via tiptap's last-defined precedence for duplicate extensions.
|
||||
*
|
||||
* Known limitation: this only inspects top-level extensions. A `TextDirection`
|
||||
* bundled inside a kit (e.g. `StarterKit`) is not detected for override
|
||||
* purposes — today no shipped kit includes `TextDirection`, so this is purely
|
||||
* theoretical.
|
||||
*/
|
||||
export function applyStaticEditorOptionsToExtensions(
|
||||
extensions: Extensions,
|
||||
options?: StaticEditorOptions,
|
||||
): Extensions {
|
||||
if (!options?.textDirection) {
|
||||
return extensions
|
||||
}
|
||||
|
||||
return [
|
||||
coreExtensions.TextDirection.configure({ direction: options.textDirection }),
|
||||
...extensions,
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* This takes a NodeExtension and maps it to a React component
|
||||
* @param extension The node extension to map to a React component
|
||||
* @param extensionAttributes All available extension attributes
|
||||
* @returns A tuple with the name of the extension and a React component that renders the extension
|
||||
*/
|
||||
export function mapNodeExtensionToReactNode<T>(
|
||||
domOutputSpecToElement: DomOutputSpecToElement<T>,
|
||||
extension: NodeExtension,
|
||||
extensionAttributes: ExtensionAttribute[],
|
||||
options?: Partial<Pick<TiptapStaticRendererOptions<T, Mark, PMNode>, 'unhandledNode'>>,
|
||||
): [string, (props: NodeProps<PMNode, T | T[]>) => T] {
|
||||
const context = {
|
||||
name: extension.name,
|
||||
options: extension.options,
|
||||
storage: extension.storage,
|
||||
parent: extension.parent,
|
||||
}
|
||||
|
||||
const renderToHTML = getExtensionField<NodeConfig['renderHTML']>(extension, 'renderHTML', context)
|
||||
|
||||
if (!renderToHTML) {
|
||||
if (options?.unhandledNode) {
|
||||
return [extension.name, options.unhandledNode]
|
||||
}
|
||||
return [
|
||||
extension.name,
|
||||
() => {
|
||||
throw new Error(
|
||||
`[tiptap error]: Node ${extension.name} cannot be rendered, it is missing a "renderToHTML" method, please implement it or override the corresponding "nodeMapping" method to have a custom rendering`,
|
||||
)
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
extension.name,
|
||||
({ node, children }) => {
|
||||
try {
|
||||
return domOutputSpecToElement(
|
||||
renderToHTML({
|
||||
node,
|
||||
HTMLAttributes: getHTMLAttributes(node, extensionAttributes),
|
||||
}),
|
||||
)(children)
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[tiptap error]: Node ${
|
||||
extension.name
|
||||
} cannot be rendered, it's "renderToHTML" method threw an error: ${(e as Error).message}`,
|
||||
{ cause: e },
|
||||
)
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* This takes a MarkExtension and maps it to a React component
|
||||
* @param extension The mark extension to map to a React component
|
||||
* @param extensionAttributes All available extension attributes
|
||||
* @returns A tuple with the name of the extension and a React component that renders the extension
|
||||
*/
|
||||
export function mapMarkExtensionToReactNode<T>(
|
||||
domOutputSpecToElement: DomOutputSpecToElement<T>,
|
||||
extension: MarkExtension,
|
||||
extensionAttributes: ExtensionAttribute[],
|
||||
options?: Partial<Pick<TiptapStaticRendererOptions<T, Mark, PMNode>, 'unhandledMark'>>,
|
||||
): [string, (props: MarkProps<Mark, T | T[]>) => T] {
|
||||
const context = {
|
||||
name: extension.name,
|
||||
options: extension.options,
|
||||
storage: extension.storage,
|
||||
parent: extension.parent,
|
||||
}
|
||||
|
||||
const renderToHTML = getExtensionField<MarkConfig['renderHTML']>(extension, 'renderHTML', context)
|
||||
|
||||
if (!renderToHTML) {
|
||||
if (options?.unhandledMark) {
|
||||
return [extension.name, options.unhandledMark]
|
||||
}
|
||||
return [
|
||||
extension.name,
|
||||
() => {
|
||||
throw new Error(
|
||||
`Node ${extension.name} cannot be rendered, it is missing a "renderToHTML" method`,
|
||||
)
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
extension.name,
|
||||
({ mark, children }) => {
|
||||
try {
|
||||
return domOutputSpecToElement(
|
||||
renderToHTML({
|
||||
mark,
|
||||
HTMLAttributes: getHTMLAttributes(mark, extensionAttributes),
|
||||
}),
|
||||
)(children)
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[tiptap error]: Mark ${
|
||||
extension.name
|
||||
} cannot be rendered, it's "renderToHTML" method threw an error: ${(e as Error).message}`,
|
||||
{ cause: e },
|
||||
)
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Placeholder node/mark types injected into a copy of the schema so JSON that
|
||||
// references types missing from the user's schema can still be converted by
|
||||
// `Node.fromJSON`. The original type/attrs are carried on the placeholder and
|
||||
// restored when it reaches `unhandledNode`/`unhandledMark`.
|
||||
const UNHANDLED_NODE_TYPE = '__tiptapUnhandledNode__'
|
||||
const UNHANDLED_MARK_TYPE = '__tiptapUnhandledMark__'
|
||||
const ORIGINAL_TYPE_ATTR = '__originalType'
|
||||
const ORIGINAL_ATTRS_ATTR = '__originalAttrs'
|
||||
|
||||
const placeholderAttrs = {
|
||||
[ORIGINAL_TYPE_ATTR]: { default: '' },
|
||||
[ORIGINAL_ATTRS_ATTR]: { default: {} },
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `content` references any node or mark type absent from `schema`.
|
||||
*/
|
||||
function hasUnknownType(content: JSONContent, schema: Schema): boolean {
|
||||
let found = false
|
||||
|
||||
const visit = (node: JSONContent): void => {
|
||||
if (found) {
|
||||
return
|
||||
}
|
||||
if (node.type && !(node.type in schema.nodes)) {
|
||||
found = true
|
||||
}
|
||||
node.marks?.forEach(mark => {
|
||||
if (mark.type && !(mark.type in schema.marks)) {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
node.content?.forEach(visit)
|
||||
}
|
||||
|
||||
visit(content)
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deep copy of `content` in which every node/mark type absent from
|
||||
* `schema` AND covered by a matching fallback in `options` is replaced by a
|
||||
* placeholder type carrying the original type/attrs. Unknown types without a
|
||||
* matching fallback are left untouched so `Node.fromJSON` still surfaces its clear
|
||||
* error for them.
|
||||
*/
|
||||
function substituteUnknownTypes(
|
||||
content: JSONContent,
|
||||
schema: Schema,
|
||||
options?: { unhandledNode?: unknown; unhandledMark?: unknown },
|
||||
): JSONContent {
|
||||
const canSubstituteNode = Boolean(options?.unhandledNode)
|
||||
const canSubstituteMark = Boolean(options?.unhandledMark)
|
||||
|
||||
const substituteMark = (mark: NonNullable<JSONContent['marks']>[number]) =>
|
||||
canSubstituteMark && !(mark.type in schema.marks)
|
||||
? {
|
||||
type: UNHANDLED_MARK_TYPE,
|
||||
attrs: { [ORIGINAL_TYPE_ATTR]: mark.type, [ORIGINAL_ATTRS_ATTR]: mark.attrs ?? {} },
|
||||
}
|
||||
: mark
|
||||
|
||||
const isUnknownNode = (node: JSONContent) =>
|
||||
canSubstituteNode && node.type != null && !(node.type in schema.nodes)
|
||||
|
||||
const toPlaceholder = (node: JSONContent, rewritten: JSONContent): JSONContent => ({
|
||||
...rewritten,
|
||||
type: UNHANDLED_NODE_TYPE,
|
||||
attrs: { [ORIGINAL_TYPE_ATTR]: node.type, [ORIGINAL_ATTRS_ATTR]: node.attrs ?? {} },
|
||||
})
|
||||
|
||||
const substituteNode = (node: JSONContent): JSONContent => {
|
||||
const rewritten = {
|
||||
...node,
|
||||
marks: node.marks?.map(substituteMark),
|
||||
content: node.content?.map(substituteNode),
|
||||
}
|
||||
return isUnknownNode(node) ? toPlaceholder(node, rewritten) : rewritten
|
||||
}
|
||||
|
||||
return substituteNode(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a copy of `schema` with the placeholder node/mark types added, so a
|
||||
* document produced by `substituteUnknownTypes` converts cleanly.
|
||||
*/
|
||||
function withPlaceholderTypes(schema: Schema): Schema {
|
||||
return new Schema({
|
||||
topNode: schema.spec.topNode,
|
||||
nodes: schema.spec.nodes.addToEnd(UNHANDLED_NODE_TYPE, { attrs: placeholderAttrs }),
|
||||
marks: schema.spec.marks.addToEnd(UNHANDLED_MARK_TYPE, { attrs: placeholderAttrs }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves render input to a ProseMirror `Node`. A `Node` is returned as-is. JSON
|
||||
* with only known types converts directly. JSON containing types missing from the
|
||||
* schema has those types — when a matching `unhandledNode`/`unhandledMark` fallback
|
||||
* exists — swapped for placeholders before conversion, so known nodes keep their
|
||||
* materialized attributes and only the unknown ones route to the fallbacks. Unknown
|
||||
* types without a fallback, and genuinely malformed content, still throw.
|
||||
*/
|
||||
function resolveRenderContent(
|
||||
content: PMNode | JSONContent,
|
||||
extensions: Extensions,
|
||||
options?: { unhandledNode?: unknown; unhandledMark?: unknown },
|
||||
): PMNode {
|
||||
if (content instanceof PMNode) {
|
||||
return content
|
||||
}
|
||||
|
||||
const schema = getSchemaByResolvedExtensions(extensions)
|
||||
|
||||
if (!hasUnknownType(content, schema)) {
|
||||
return PMNode.fromJSON(schema, content)
|
||||
}
|
||||
|
||||
return PMNode.fromJSON(
|
||||
withPlaceholderTypes(schema),
|
||||
substituteUnknownTypes(content, schema, options),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps a placeholder node/mark JSON back to its original type and attributes.
|
||||
* Leaves non-placeholder JSON untouched.
|
||||
*/
|
||||
function unwrapPlaceholderJSON(json: JSONContent): JSONContent {
|
||||
return json.type === UNHANDLED_NODE_TYPE || json.type === UNHANDLED_MARK_TYPE
|
||||
? { ...json, type: json.attrs![ORIGINAL_TYPE_ATTR], attrs: json.attrs![ORIGINAL_ATTRS_ATTR] }
|
||||
: json
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively restores the original JSON of a (possibly nested) placeholder
|
||||
* subtree, including placeholder marks.
|
||||
*/
|
||||
function restoreOriginalJSON(json: JSONContent): JSONContent {
|
||||
const node = unwrapPlaceholderJSON(json)
|
||||
|
||||
return {
|
||||
...node,
|
||||
marks: node.marks?.map(
|
||||
mark => unwrapPlaceholderJSON(mark) as NonNullable<JSONContent['marks']>[number],
|
||||
),
|
||||
content: node.content?.map(restoreOriginalJSON),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Presents a placeholder node/mark to a fallback renderer under its original type
|
||||
* name and attributes — including a `toJSON()` that returns the restored original
|
||||
* JSON — while keeping the live ProseMirror children/methods intact.
|
||||
*
|
||||
* `type` is exposed as a minimal `{ name }` rather than a real `NodeType`/`MarkType`:
|
||||
* the original type is absent from the schema so no real one exists, and surfacing
|
||||
* the placeholder's own type would leak misleading `spec`/`schema`/`isInline` values.
|
||||
*/
|
||||
function withOriginalIdentity<T extends { attrs: Record<string, any>; toJSON: () => JSONContent }>(
|
||||
target: T,
|
||||
): T {
|
||||
const overrides: Record<string | symbol, unknown> = Object.assign(Object.create(null), {
|
||||
type: { name: target.attrs[ORIGINAL_TYPE_ATTR] },
|
||||
attrs: target.attrs[ORIGINAL_ATTRS_ATTR],
|
||||
toJSON: () => restoreOriginalJSON(target.toJSON()),
|
||||
})
|
||||
|
||||
return new Proxy(target, {
|
||||
get(node, prop) {
|
||||
const override = overrides[prop]
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
}
|
||||
const value = (node as Record<string | symbol, unknown>)[prop]
|
||||
return typeof value === 'function' ? value.bind(node) : value
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Maps the placeholder node type to the caller's `unhandledNode`, restoring identity. */
|
||||
function placeholderNodeRenderer<T>(
|
||||
unhandledNode: (props: NodeProps<PMNode, T | T[]>) => T,
|
||||
): (props: NodeProps<PMNode, T | T[]>) => T {
|
||||
return props => unhandledNode({ ...props, node: withOriginalIdentity(props.node) })
|
||||
}
|
||||
|
||||
/** Maps the placeholder mark type to the caller's `unhandledMark`, restoring identity. */
|
||||
function placeholderMarkRenderer<T>(
|
||||
unhandledMark: (props: MarkProps<Mark, T | T[], PMNode>) => T,
|
||||
): (props: MarkProps<Mark, T | T[], PMNode>) => T {
|
||||
return props => unhandledMark({ ...props, mark: withOriginalIdentity(props.mark) })
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will statically render a Prosemirror Node to a target element type using the given extensions
|
||||
* @param renderer The renderer to use to render the Prosemirror Node to the target element type
|
||||
* @param domOutputSpecToElement A function that takes a Prosemirror DOMOutputSpec and returns a function that takes children and returns the target element type
|
||||
* @param mapDefinedTypes An object with functions to map the doc and text types to the target element type
|
||||
* @param content The Prosemirror Node to render
|
||||
* @param extensions The extensions to use to render the Prosemirror Node
|
||||
* @param options Additional options to pass to the renderer that can override the default behavior
|
||||
* @returns The rendered target element type
|
||||
*/
|
||||
export function renderToElement<T>({
|
||||
renderer,
|
||||
domOutputSpecToElement,
|
||||
mapDefinedTypes,
|
||||
content,
|
||||
extensions,
|
||||
options,
|
||||
}: {
|
||||
renderer: (
|
||||
options: TiptapStaticRendererOptions<T, Mark, PMNode>,
|
||||
) => (ctx: { content: PMNode }) => T
|
||||
domOutputSpecToElement: DomOutputSpecToElement<T>
|
||||
mapDefinedTypes: {
|
||||
doc: (props: NodeProps<PMNode, T | T[]>) => T
|
||||
text: (props: NodeProps<PMNode, T | T[]>) => T
|
||||
}
|
||||
content: PMNode | JSONContent
|
||||
extensions: Extensions
|
||||
options?: Partial<TiptapStaticRendererOptions<T, Mark, PMNode>>
|
||||
}): T {
|
||||
// get all extensions in order & split them into nodes and marks
|
||||
extensions = resolveExtensions(extensions)
|
||||
const extensionAttributes = getAttributesFromExtensions(extensions)
|
||||
const { nodeExtensions, markExtensions } = splitExtensions(extensions)
|
||||
const {
|
||||
unhandledNode,
|
||||
unhandledMark,
|
||||
nodeMapping: userNodeMapping,
|
||||
markMapping: userMarkMapping,
|
||||
} = options ?? {}
|
||||
|
||||
content = resolveRenderContent(content, extensions, options)
|
||||
|
||||
return renderer({
|
||||
...options,
|
||||
nodeMapping: {
|
||||
...Object.fromEntries(
|
||||
nodeExtensions
|
||||
.filter(e => {
|
||||
if (e.name in mapDefinedTypes) {
|
||||
// These are predefined types that we don't need to map
|
||||
return false
|
||||
}
|
||||
// No need to generate mappings for nodes that are already mapped
|
||||
if (userNodeMapping) {
|
||||
return !(e.name in userNodeMapping)
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(nodeExtension =>
|
||||
mapNodeExtensionToReactNode<T>(
|
||||
domOutputSpecToElement,
|
||||
nodeExtension,
|
||||
extensionAttributes,
|
||||
options,
|
||||
),
|
||||
),
|
||||
),
|
||||
...mapDefinedTypes,
|
||||
...userNodeMapping,
|
||||
// Route substituted unknown nodes to the caller's fallback (see resolveRenderContent).
|
||||
...(unhandledNode
|
||||
? { [UNHANDLED_NODE_TYPE]: placeholderNodeRenderer<T>(unhandledNode) }
|
||||
: {}),
|
||||
},
|
||||
markMapping: {
|
||||
...Object.fromEntries(
|
||||
markExtensions
|
||||
.filter(e => {
|
||||
// No need to generate mappings for marks that are already mapped
|
||||
if (userMarkMapping) {
|
||||
return !(e.name in userMarkMapping)
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(mark =>
|
||||
mapMarkExtensionToReactNode<T>(
|
||||
domOutputSpecToElement,
|
||||
mark,
|
||||
extensionAttributes,
|
||||
options,
|
||||
),
|
||||
),
|
||||
),
|
||||
...userMarkMapping,
|
||||
// Route substituted unknown marks to the caller's fallback (see resolveRenderContent).
|
||||
...(unhandledMark
|
||||
? { [UNHANDLED_MARK_TYPE]: placeholderMarkRenderer<T>(unhandledMark) }
|
||||
: {}),
|
||||
},
|
||||
})({ content })
|
||||
}
|
||||
362
packages/static-renderer/src/pm/html-string/html-string.test.ts
Normal file
362
packages/static-renderer/src/pm/html-string/html-string.test.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { extensions as coreExtensions } from '@tiptap/core'
|
||||
import Bold from '@tiptap/extension-bold'
|
||||
import CodeBlock from '@tiptap/extension-code-block'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Heading from '@tiptap/extension-heading'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import { generateTocIds, TableOfContents } from '@tiptap/extension-table-of-contents'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { generateUniqueIds, UniqueID } from '@tiptap/extension-unique-id'
|
||||
import Youtube from '@tiptap/extension-youtube'
|
||||
import { Mark, Node as PMNode } from '@tiptap/pm/model'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import {
|
||||
serializeAttrsToHTMLString,
|
||||
serializeChildrenToHTMLString,
|
||||
} from '../../json/html-string/string.js'
|
||||
import { domOutputSpecToHTMLString, renderToHTMLString } from './html-string.js'
|
||||
|
||||
describe('static render json to string (with prosemirror)', () => {
|
||||
it('generates an HTML string 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 html = renderToHTMLString({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text, Bold],
|
||||
})
|
||||
|
||||
expect(html).toBe('<p><strong>Example Text</strong></p>')
|
||||
})
|
||||
|
||||
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 html = renderToHTMLString({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text, Bold],
|
||||
options: {
|
||||
nodeMapping: {
|
||||
doc: ({ children }) => {
|
||||
return `<doc>${serializeChildrenToHTMLString(children)}</doc>`
|
||||
},
|
||||
},
|
||||
markMapping: {
|
||||
bold: ({ children }) => {
|
||||
return `<b>${serializeChildrenToHTMLString(children)}</b>`
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(html).toBe('<doc><p><b>Example Text</b></p></doc>')
|
||||
})
|
||||
|
||||
it('escapes text node content', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '<img src=x onerror=alert(document.cookie)> Tom & Jerry',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const html = renderToHTMLString({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text],
|
||||
})
|
||||
|
||||
expect(html).toBe('<p><img src=x onerror=alert(document.cookie)> Tom & Jerry</p>')
|
||||
})
|
||||
|
||||
it('escapes attribute values in the ProseMirror HTML string renderer', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Click here',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://tiptap.dev/?q="><img src=x onerror=alert(document.cookie)>',
|
||||
target: '_blank',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const html = renderToHTMLString({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text, Link],
|
||||
})
|
||||
|
||||
expect(html).toContain(
|
||||
'href="https://tiptap.dev/?q="><img src=x onerror=alert(document.cookie)>"',
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes string DOM output specs as text content', () => {
|
||||
const html = domOutputSpecToHTMLString('<img src=x onerror=alert(document.cookie)>')()
|
||||
|
||||
expect(html).toBe('<img src=x onerror=alert(document.cookie)>')
|
||||
})
|
||||
|
||||
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 html = renderToHTMLString({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text, Bold],
|
||||
options: {
|
||||
nodeMapping: {
|
||||
doc: ({ children, node }) => {
|
||||
expect(node.type.name).toBe('doc')
|
||||
expect(node).toBeInstanceOf(PMNode)
|
||||
return `<doc>${serializeChildrenToHTMLString(children)}</doc>`
|
||||
},
|
||||
},
|
||||
markMapping: {
|
||||
bold: ({ children, mark }) => {
|
||||
expect(mark.type.name).toBe('bold')
|
||||
expect(mark).toBeInstanceOf(Mark)
|
||||
return `<b>${serializeChildrenToHTMLString(children)}</b>`
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(html).toBe('<doc><p><b>Example Text</b></p></doc>')
|
||||
})
|
||||
|
||||
it('renders youtube extension followed by other nodes correctly', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'youtube',
|
||||
attrs: {
|
||||
src: 'https://www.youtube.com/watch?v=3lTUAWOgoHs',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'text after youtube',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const html = renderToHTMLString({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text, Youtube],
|
||||
})
|
||||
|
||||
expect(html).to.include('data-youtube-video')
|
||||
expect(html).to.include('<p>text after youtube</p>')
|
||||
})
|
||||
|
||||
const headingDoc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it.each(['ltr', 'rtl', 'auto'] as const)(
|
||||
'honors textDirection=%s via staticEditorOptions',
|
||||
direction => {
|
||||
const html = renderToHTMLString({
|
||||
content: headingDoc,
|
||||
extensions: [Document, Paragraph, Text, Heading],
|
||||
staticEditorOptions: { textDirection: direction },
|
||||
})
|
||||
|
||||
expect(html).toContain(`dir="${direction}"`)
|
||||
},
|
||||
)
|
||||
|
||||
it('does not add a dir attribute when staticEditorOptions is unset', () => {
|
||||
const html = renderToHTMLString({
|
||||
content: headingDoc,
|
||||
extensions: [Document, Paragraph, Text, Heading],
|
||||
})
|
||||
|
||||
expect(html).not.toContain('dir=')
|
||||
})
|
||||
|
||||
it('a user-supplied TextDirection wins over staticEditorOptions.textDirection (matches Editor precedence)', () => {
|
||||
// Editor.ts prepends its `textDirection`-driven TextDirection to
|
||||
// `this.options.extensions`, so a user-supplied TextDirection — coming
|
||||
// later in the array — wins via tiptap's last-defined precedence for
|
||||
// duplicate `addGlobalAttributes`. The static renderer mirrors that.
|
||||
const html = renderToHTMLString({
|
||||
content: headingDoc,
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Heading,
|
||||
coreExtensions.TextDirection.configure({ direction: 'ltr' }),
|
||||
],
|
||||
staticEditorOptions: { textDirection: 'rtl' },
|
||||
})
|
||||
|
||||
expect(html).toContain('dir="ltr"')
|
||||
expect(html).not.toContain('dir="rtl"')
|
||||
})
|
||||
|
||||
it('omits null and undefined attribute values instead of serializing them as strings', () => {
|
||||
expect(serializeAttrsToHTMLString({ class: null, id: undefined, dir: 'auto' })).toBe(
|
||||
' dir="auto"',
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves false attribute values (matches ProseMirror DOMSerializer)', () => {
|
||||
expect(serializeAttrsToHTMLString({ contenteditable: false })).toBe(' contenteditable="false"')
|
||||
})
|
||||
|
||||
it('does not emit class="null" for a CodeBlock without language', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'codeBlock',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const html = renderToHTMLString({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text, CodeBlock],
|
||||
})
|
||||
|
||||
expect(html).toBe('<pre><code>hello</code></pre>')
|
||||
})
|
||||
|
||||
it('does not emit id="null" / data-toc-id="null" for TableOfContents nodes without IDs', () => {
|
||||
const html = renderToHTMLString({
|
||||
content: headingDoc,
|
||||
extensions: [Document, Paragraph, Text, Heading, TableOfContents],
|
||||
})
|
||||
|
||||
expect(html).not.toContain('id="null"')
|
||||
expect(html).not.toContain('data-toc-id="null"')
|
||||
})
|
||||
|
||||
it('renders UniqueID data-id attributes when JSON is pre-processed with generateUniqueIds', () => {
|
||||
const extensionsWithUid = [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Heading,
|
||||
UniqueID.configure({ types: ['heading'] }),
|
||||
]
|
||||
const docWithIds = generateUniqueIds(headingDoc, extensionsWithUid)
|
||||
|
||||
const html = renderToHTMLString({ content: docWithIds, extensions: extensionsWithUid })
|
||||
|
||||
expect(html).toMatch(/data-id="[^"]+"/)
|
||||
})
|
||||
|
||||
it('renders TableOfContents anchor IDs when JSON is pre-processed with generateTocIds', () => {
|
||||
const extensionsWithToc = [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Heading,
|
||||
TableOfContents.configure({ anchorTypes: ['heading'] }),
|
||||
]
|
||||
const docWithIds = generateTocIds(headingDoc, extensionsWithToc)
|
||||
|
||||
const html = renderToHTMLString({ content: docWithIds, extensions: extensionsWithToc })
|
||||
|
||||
expect(html).toMatch(/\bid="[^"]+"/)
|
||||
expect(html).toMatch(/data-toc-id="[^"]+"/)
|
||||
})
|
||||
})
|
||||
145
packages/static-renderer/src/pm/html-string/html-string.ts
Normal file
145
packages/static-renderer/src/pm/html-string/html-string.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/* oslint-disableno-explicit-any */
|
||||
import type { DOMOutputSpecArray, Extensions, JSONContent } from '@tiptap/core'
|
||||
import type { DOMOutputSpec, Mark, Node as PMNode } from '@tiptap/pm/model'
|
||||
|
||||
import {
|
||||
escapeHTML,
|
||||
renderJSONContentToString,
|
||||
serializeAttrsToHTMLString,
|
||||
serializeChildrenToHTMLString,
|
||||
} from '../../json/html-string/string.js'
|
||||
import type { TiptapStaticRendererOptions } from '../../json/renderer.js'
|
||||
import type { StaticEditorOptions } from '../extensionRenderer.js'
|
||||
import { applyStaticEditorOptionsToExtensions, renderToElement } from '../extensionRenderer.js'
|
||||
|
||||
export {
|
||||
serializeAttrsToHTMLString,
|
||||
serializeChildrenToHTMLString,
|
||||
} from '../../json/html-string/string.js'
|
||||
|
||||
/**
|
||||
* HTML elements that cannot be self-closing and must always have a closing tag.
|
||||
* These elements must be rendered as <tag></tag> even when empty, not <tag />.
|
||||
*/
|
||||
const NON_SELF_CLOSING_TAGS = new Set([
|
||||
'iframe',
|
||||
'script',
|
||||
'style',
|
||||
'title',
|
||||
'textarea',
|
||||
'div',
|
||||
'span',
|
||||
'a',
|
||||
'button',
|
||||
])
|
||||
|
||||
/**
|
||||
* Take a DOMOutputSpec and return a function that can render it to a string
|
||||
* @param content The DOMOutputSpec to convert to a string
|
||||
* @returns A function that can render the DOMOutputSpec to a string
|
||||
*/
|
||||
export function domOutputSpecToHTMLString(
|
||||
content: DOMOutputSpec,
|
||||
): (children?: string | string[]) => string {
|
||||
if (typeof content === 'string') {
|
||||
return () => escapeHTML(content)
|
||||
}
|
||||
if (typeof content === 'object' && 'length' in content) {
|
||||
const [_tag, attrs, children, ...rest] = content as DOMOutputSpecArray
|
||||
let tag = _tag
|
||||
const parts = tag.split(' ')
|
||||
|
||||
if (parts.length > 1) {
|
||||
tag = `${parts[1]} xmlns="${parts[0]}"`
|
||||
}
|
||||
|
||||
if (attrs === undefined) {
|
||||
return () => `<${tag}/>`
|
||||
}
|
||||
if (attrs === 0) {
|
||||
return child => `<${tag}>${serializeChildrenToHTMLString(child)}</${tag}>`
|
||||
}
|
||||
if (typeof attrs === 'object') {
|
||||
if (Array.isArray(attrs)) {
|
||||
if (children === undefined) {
|
||||
return child =>
|
||||
`<${tag}>${domOutputSpecToHTMLString(attrs as DOMOutputSpecArray)(child)}</${tag}>`
|
||||
}
|
||||
if (children === 0) {
|
||||
return child =>
|
||||
`<${tag}>${domOutputSpecToHTMLString(attrs as DOMOutputSpecArray)(child)}</${tag}>`
|
||||
}
|
||||
return child =>
|
||||
`<${tag}>${domOutputSpecToHTMLString(attrs as DOMOutputSpecArray)(child)}${[children]
|
||||
.concat(rest)
|
||||
.map(a => domOutputSpecToHTMLString(a)(child))}</${tag}>`
|
||||
}
|
||||
if (children === undefined) {
|
||||
if (NON_SELF_CLOSING_TAGS.has(tag)) {
|
||||
return () => `<${tag}${serializeAttrsToHTMLString(attrs)}></${tag}>`
|
||||
}
|
||||
return () => `<${tag}${serializeAttrsToHTMLString(attrs)}/>`
|
||||
}
|
||||
if (children === 0) {
|
||||
return child =>
|
||||
`<${tag}${serializeAttrsToHTMLString(attrs)}>${serializeChildrenToHTMLString(child)}</${tag}>`
|
||||
}
|
||||
|
||||
return child =>
|
||||
`<${tag}${serializeAttrsToHTMLString(attrs)}>${[children]
|
||||
.concat(rest)
|
||||
.map(a => domOutputSpecToHTMLString(a)(child))
|
||||
.join('')}</${tag}>`
|
||||
}
|
||||
}
|
||||
|
||||
// TODO support DOM elements? How to handle them?
|
||||
throw new Error(
|
||||
'[tiptap error]: Unsupported DomOutputSpec type, check the `renderHTML` method output or implement a node mapping',
|
||||
{
|
||||
cause: content,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will statically render a Prosemirror Node to HTML using the provided extensions and options.
|
||||
*
|
||||
* Limitations: this function builds the schema and runs each extension's
|
||||
* `renderHTML`, but does not instantiate an `Editor`. Extensions that mutate
|
||||
* the document inside `addProseMirrorPlugins`, `onCreate`, or transaction
|
||||
* hooks will not run. For UniqueID, pre-process the JSON with
|
||||
* `generateUniqueIds` from `@tiptap/extension-unique-id`; for TableOfContents,
|
||||
* pre-process with `generateTocIds` from `@tiptap/extension-table-of-contents`.
|
||||
*
|
||||
* @param content The content to render to HTML
|
||||
* @param extensions The extensions to use for rendering
|
||||
* @param staticEditorOptions Optional editor-level options that affect rendered output, currently `{ textDirection }`. Mirrors a subset of `EditorOptions`.
|
||||
* @param options The options to use for rendering
|
||||
* @returns The rendered HTML string
|
||||
*/
|
||||
export function renderToHTMLString({
|
||||
content,
|
||||
extensions,
|
||||
staticEditorOptions,
|
||||
options,
|
||||
}: {
|
||||
content: PMNode | JSONContent
|
||||
extensions: Extensions
|
||||
staticEditorOptions?: StaticEditorOptions
|
||||
options?: Partial<TiptapStaticRendererOptions<string, Mark, PMNode>>
|
||||
}): string {
|
||||
return renderToElement<string>({
|
||||
renderer: renderJSONContentToString,
|
||||
domOutputSpecToElement: domOutputSpecToHTMLString,
|
||||
mapDefinedTypes: {
|
||||
// Map a doc node to concatenated children
|
||||
doc: ({ children }) => serializeChildrenToHTMLString(children),
|
||||
// Map a text node to its text content
|
||||
text: ({ node }) => escapeHTML(node.text ?? ''),
|
||||
},
|
||||
content,
|
||||
extensions: applyStaticEditorOptionsToExtensions(extensions, staticEditorOptions),
|
||||
options,
|
||||
})
|
||||
}
|
||||
2
packages/static-renderer/src/pm/html-string/index.ts
Normal file
2
packages/static-renderer/src/pm/html-string/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from '../extensionRenderer.js'
|
||||
export * from './html-string.js'
|
||||
2
packages/static-renderer/src/pm/markdown/index.ts
Normal file
2
packages/static-renderer/src/pm/markdown/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from '../extensionRenderer.js'
|
||||
export * from './markdown.js'
|
||||
418
packages/static-renderer/src/pm/markdown/markdown.test.ts
Normal file
418
packages/static-renderer/src/pm/markdown/markdown.test.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { TableKit } from '@tiptap/extension-table'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { renderToMarkdown } from './markdown.js'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
describe('static render json to string (no prosemirror)', () => {
|
||||
it('should return empty string for empty content', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [],
|
||||
}
|
||||
const md = renderToMarkdown({
|
||||
content: json,
|
||||
extensions: [StarterKit, TableKit],
|
||||
})
|
||||
expect(md).toBe('')
|
||||
})
|
||||
|
||||
it('should render empty paragraph', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
const md = renderToMarkdown({
|
||||
content: json,
|
||||
extensions: [StarterKit, TableKit],
|
||||
})
|
||||
expect(md).toBe('\n\n')
|
||||
})
|
||||
|
||||
it('should render a simple paragraph', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello, world!',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const md = renderToMarkdown({
|
||||
content: json,
|
||||
extensions: [StarterKit, TableKit],
|
||||
})
|
||||
expect(md).toBe('\nHello, world!\n')
|
||||
})
|
||||
|
||||
it('should render a simple table', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
colwidth: null,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Col 1',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
colwidth: null,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Col 2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
colwidth: null,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Col 3',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
colwidth: null,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Row 1 1',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
colwidth: null,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '112',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
colwidth: null,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '1334',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
colwidth: null,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Row 2 1',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
colwidth: null,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '115',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
colwidth: null,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '4',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const md = renderToMarkdown({ content: json, extensions: [StarterKit, TableKit] })
|
||||
|
||||
expect(md).toBe(
|
||||
'\n| Col 1 | Col 2 | Col 3 |\n| --- | --- | --- |\n| Row 1 1 | 112 | 1334 |\n| Row 2 1 | 115 | 4 |\n\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('should render a table with merged cells (rowspan) without breaking the grid', () => {
|
||||
const cell = (text: string, attrs: { colspan?: number; rowspan?: number } = {}) => ({
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null, ...attrs },
|
||||
content: text
|
||||
? [{ type: 'paragraph', content: [{ type: 'text', text }] }]
|
||||
: [{ type: 'paragraph', content: [] }],
|
||||
})
|
||||
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
// Row 0: "3" spans down into row 1, so row 0 has 3 real cells
|
||||
type: 'tableRow',
|
||||
content: [cell('1'), cell('3', { rowspan: 2 }), cell('-')],
|
||||
},
|
||||
{
|
||||
// Row 1: only 2 real cells exist here, the 3rd column is covered by row 0's rowspan
|
||||
type: 'tableRow',
|
||||
content: [cell('2'), cell('4')],
|
||||
},
|
||||
{
|
||||
// Row 2: back to 3 real (empty) cells
|
||||
type: 'tableRow',
|
||||
content: [cell(''), cell(''), cell('')],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const md = renderToMarkdown({ content: json, extensions: [StarterKit, TableKit] })
|
||||
|
||||
// Every row must keep the same number of columns as the header separator row,
|
||||
// with a blank placeholder standing in for the rowspan-covered slot.
|
||||
expect(md).toBe('\n| 1 | 3 | - |\n| --- | --- | --- |\n| 2 | | 4 |\n| | | |\n\n')
|
||||
})
|
||||
|
||||
it('should render a blockquote with trailing newline before a paragraph', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'blockquote',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Quote',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'World',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const md = renderToMarkdown({
|
||||
content: json,
|
||||
extensions: [StarterKit, TableKit],
|
||||
})
|
||||
// The blockquote must have a trailing newline so "World" is not part of the blockquote
|
||||
expect(md).toContain('> Quote\n\n')
|
||||
})
|
||||
|
||||
it('should render a standalone blockquote with trailing newline', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'blockquote',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Quote',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const md = renderToMarkdown({
|
||||
content: json,
|
||||
extensions: [StarterKit, TableKit],
|
||||
})
|
||||
expect(md).toBe('\n> Quote\n')
|
||||
})
|
||||
|
||||
it('should render a blockquote with multiple paragraphs', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'blockquote',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'First',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Second',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const md = renderToMarkdown({
|
||||
content: json,
|
||||
extensions: [StarterKit, TableKit],
|
||||
})
|
||||
expect(md).toBe('\n> First\n> \n> Second\n')
|
||||
})
|
||||
|
||||
it('accepts staticEditorOptions.textDirection without crashing', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
|
||||
}
|
||||
|
||||
const md = renderToMarkdown({
|
||||
content: json,
|
||||
extensions: [StarterKit, TableKit],
|
||||
staticEditorOptions: { textDirection: 'rtl' },
|
||||
})
|
||||
|
||||
expect(md).toBe('\nhello\n')
|
||||
})
|
||||
})
|
||||
188
packages/static-renderer/src/pm/markdown/markdown.ts
Normal file
188
packages/static-renderer/src/pm/markdown/markdown.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { Extensions, JSONContent } from '@tiptap/core'
|
||||
import type { Mark, Node as PMNode } from '@tiptap/pm/model'
|
||||
import { TableMap } from '@tiptap/pm/tables'
|
||||
|
||||
import type { TiptapStaticRendererOptions } from '../../json/renderer.js'
|
||||
import type { StaticEditorOptions } from '../extensionRenderer.js'
|
||||
import { renderToHTMLString, serializeChildrenToHTMLString } from '../html-string/html-string.js'
|
||||
|
||||
/**
|
||||
* This code is just to show the flexibility of this renderer. We can potentially render content to any format we want.
|
||||
* This is a simple example of how we can render content to markdown. This is not a full implementation of a markdown renderer.
|
||||
*
|
||||
* Limitations: see `renderToHTMLString` — extensions that mutate the document
|
||||
* via plugins/onCreate (UniqueID, TableOfContents) need to be pre-processed.
|
||||
*
|
||||
* @param staticEditorOptions Optional editor-level options that affect rendered output — mirrors a subset of `EditorOptions`.
|
||||
*/
|
||||
export function renderToMarkdown({
|
||||
content,
|
||||
extensions,
|
||||
staticEditorOptions,
|
||||
options,
|
||||
}: {
|
||||
content: PMNode | JSONContent
|
||||
extensions: Extensions
|
||||
staticEditorOptions?: StaticEditorOptions
|
||||
options?: Partial<TiptapStaticRendererOptions<string, Mark, PMNode>>
|
||||
}) {
|
||||
return renderToHTMLString({
|
||||
content,
|
||||
extensions,
|
||||
staticEditorOptions,
|
||||
options: {
|
||||
...options,
|
||||
nodeMapping: {
|
||||
bulletList({ children }) {
|
||||
return `\n${serializeChildrenToHTMLString(children)}`
|
||||
},
|
||||
orderedList({ children }) {
|
||||
return `\n${serializeChildrenToHTMLString(children)}`
|
||||
},
|
||||
listItem({ node, children, parent }) {
|
||||
if (parent?.type.name === 'bulletList') {
|
||||
return `- ${serializeChildrenToHTMLString(children).trim()}\n`
|
||||
}
|
||||
if (parent?.type.name === 'orderedList') {
|
||||
let number = parent.attrs.start || 1
|
||||
|
||||
parent.forEach((parentChild, _offset, index) => {
|
||||
if (node === parentChild) {
|
||||
number = index + 1
|
||||
}
|
||||
})
|
||||
|
||||
return `${number}. ${serializeChildrenToHTMLString(children).trim()}\n`
|
||||
}
|
||||
|
||||
return serializeChildrenToHTMLString(children)
|
||||
},
|
||||
paragraph({ children }) {
|
||||
return `\n${serializeChildrenToHTMLString(children)}\n`
|
||||
},
|
||||
heading({ node, children }) {
|
||||
const level = node.attrs.level as number
|
||||
|
||||
return `${Array.from<string>({ length: level }).fill('#').join('')} ${children}\n`
|
||||
},
|
||||
codeBlock({ node, children }) {
|
||||
return `\n\`\`\`${node.attrs.language}\n${serializeChildrenToHTMLString(children)}\n\`\`\`\n`
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return `\n${serializeChildrenToHTMLString(children)
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(a => `> ${a}`)
|
||||
.join('\n')}\n`
|
||||
},
|
||||
image({ node }) {
|
||||
return ``
|
||||
},
|
||||
hardBreak() {
|
||||
return '\n'
|
||||
},
|
||||
horizontalRule() {
|
||||
return '\n---\n'
|
||||
},
|
||||
table({ children, node }) {
|
||||
if (!Array.isArray(children)) {
|
||||
return `\n${serializeChildrenToHTMLString(children)}\n`
|
||||
}
|
||||
|
||||
// Use the table's real column count (accounting for row/colspan) rather than
|
||||
// assuming the first row spans every column.
|
||||
const columnCount = TableMap.get(node).width
|
||||
return `\n${serializeChildrenToHTMLString(children[0])}| ${Array.from<string>({ length: columnCount }).fill('---').join(' | ')} |\n${serializeChildrenToHTMLString(children.slice(1))}\n`
|
||||
},
|
||||
tableRow({ node, children, parent }) {
|
||||
if (!Array.isArray(children) || !parent) {
|
||||
return `${serializeChildrenToHTMLString(children)}\n`
|
||||
}
|
||||
|
||||
const map = TableMap.get(parent)
|
||||
|
||||
let rowIndex = -1
|
||||
parent.forEach((rowNode, _offset, index) => {
|
||||
if (rowNode === node) {
|
||||
rowIndex = index
|
||||
}
|
||||
})
|
||||
|
||||
if (rowIndex === -1) {
|
||||
// Should not happen, but fall back to the previous (naive) behavior rather than throwing.
|
||||
return `| ${children.join(' | ')} |\n`
|
||||
}
|
||||
|
||||
const cells: string[] = []
|
||||
let cellIndex = 0
|
||||
|
||||
for (let col = 0; col < map.width; col += 1) {
|
||||
const cellPos = map.map[rowIndex * map.width + col]
|
||||
const rect = map.findCell(cellPos)
|
||||
|
||||
// Only the cell's top-left slot corresponds to an actual rendered child of this row.
|
||||
// Every other slot it covers (via rowspan/colspan) needs a blank placeholder so the
|
||||
// markdown grid stays rectangular.
|
||||
if (rect.top === rowIndex && rect.left === col) {
|
||||
cells.push(children[cellIndex] ?? '')
|
||||
cellIndex += 1
|
||||
} else {
|
||||
cells.push('')
|
||||
}
|
||||
}
|
||||
|
||||
return `| ${cells.join(' | ')} |\n`
|
||||
},
|
||||
tableHeader({ children }) {
|
||||
return serializeChildrenToHTMLString(children).trim()
|
||||
},
|
||||
tableCell({ children }) {
|
||||
return serializeChildrenToHTMLString(children).trim()
|
||||
},
|
||||
...options?.nodeMapping,
|
||||
},
|
||||
markMapping: {
|
||||
bold({ children }) {
|
||||
return `**${serializeChildrenToHTMLString(children)}**`
|
||||
},
|
||||
italic({ children, node }) {
|
||||
let isBoldToo = false
|
||||
|
||||
// Check if the node being wrapped also has a bold mark, if so, we need to use the bold markdown syntax
|
||||
if (node?.marks.some(m => m.type.name === 'bold')) {
|
||||
isBoldToo = true
|
||||
}
|
||||
|
||||
if (isBoldToo) {
|
||||
// If the content is bold, just wrap the bold content in italic markdown syntax with another set of asterisks
|
||||
return `*${serializeChildrenToHTMLString(children)}*`
|
||||
}
|
||||
|
||||
return `_${serializeChildrenToHTMLString(children)}_`
|
||||
},
|
||||
code({ children }) {
|
||||
return `\`${serializeChildrenToHTMLString(children)}\``
|
||||
},
|
||||
strike({ children }) {
|
||||
return `~~${serializeChildrenToHTMLString(children)}~~`
|
||||
},
|
||||
underline({ children }) {
|
||||
return `<u>${serializeChildrenToHTMLString(children)}</u>`
|
||||
},
|
||||
subscript({ children }) {
|
||||
return `<sub>${serializeChildrenToHTMLString(children)}</sub>`
|
||||
},
|
||||
superscript({ children }) {
|
||||
return `<sup>${serializeChildrenToHTMLString(children)}</sup>`
|
||||
},
|
||||
link({ mark, children }) {
|
||||
return `[${serializeChildrenToHTMLString(children)}](${mark.attrs.href})`
|
||||
},
|
||||
highlight({ children }) {
|
||||
return `==${serializeChildrenToHTMLString(children)}==`
|
||||
},
|
||||
...options?.markMapping,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
2
packages/static-renderer/src/pm/react/index.ts
Normal file
2
packages/static-renderer/src/pm/react/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from '../extensionRenderer.js'
|
||||
export * from './react.js'
|
||||
211
packages/static-renderer/src/pm/react/react.test.ts
Normal file
211
packages/static-renderer/src/pm/react/react.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { prettyDOM, render } from '@testing-library/react'
|
||||
import Bold from '@tiptap/extension-bold'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Heading from '@tiptap/extension-heading'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { Mark, Node as PMNode } from '@tiptap/pm/model'
|
||||
import { mapAttrsToHTMLAttributes, renderToReactElement } from './react.js'
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
describe('static renderer: react', () => {
|
||||
it('mapAttrsToHTMLAttributes maps attributes to React attributes', () => {
|
||||
const attrs = {
|
||||
class: 'my-class',
|
||||
style: 'color: red; font-size: 16px; transform: translateX(15px) translateY(10px);',
|
||||
id: 'my-id',
|
||||
colspan: 2,
|
||||
rowspan: 3,
|
||||
}
|
||||
|
||||
const result = mapAttrsToHTMLAttributes(attrs, 'test-key')
|
||||
|
||||
expect(result).toEqual({
|
||||
className: 'my-class',
|
||||
style: { color: 'red', fontSize: '16px', transform: 'translateX(15px) translateY(10px)' },
|
||||
id: 'my-id',
|
||||
colSpan: 2,
|
||||
rowSpan: 3,
|
||||
key: 'test-key',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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).toBe(`<div>
|
||||
<p>
|
||||
<strong>
|
||||
Example Text
|
||||
</strong>
|
||||
</p>
|
||||
</div>`)
|
||||
})
|
||||
|
||||
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).toBe(`<div>
|
||||
<doc>
|
||||
<p>
|
||||
<b>
|
||||
Example Text
|
||||
</b>
|
||||
</p>
|
||||
</doc>
|
||||
</div>`)
|
||||
})
|
||||
|
||||
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).toBe('doc')
|
||||
expect(node).toBeInstanceOf(PMNode)
|
||||
return React.createElement('doc', {}, children)
|
||||
},
|
||||
},
|
||||
markMapping: {
|
||||
bold: ({ children, mark }) => {
|
||||
expect(mark.type.name).toBe('bold')
|
||||
expect(mark).toBeInstanceOf(Mark)
|
||||
return React.createElement('b', {}, children)
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const html = prettyDOM(view.container, undefined, { highlight: false })
|
||||
|
||||
expect(html).toBe(`<div>
|
||||
<doc>
|
||||
<p>
|
||||
<b>
|
||||
Example Text
|
||||
</b>
|
||||
</p>
|
||||
</doc>
|
||||
</div>`)
|
||||
})
|
||||
|
||||
it('honors textDirection via staticEditorOptions', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const view = render(
|
||||
renderToReactElement({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text, Heading],
|
||||
staticEditorOptions: { textDirection: 'rtl' },
|
||||
}),
|
||||
)
|
||||
|
||||
const heading = view.container.querySelector('h1')
|
||||
expect(heading?.getAttribute('dir')).toBe('rtl')
|
||||
})
|
||||
})
|
||||
193
packages/static-renderer/src/pm/react/react.ts
Normal file
193
packages/static-renderer/src/pm/react/react.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/* oslint-disable no-plusplus,no-explicit-any */
|
||||
import type { DOMOutputSpecArray, Extensions, JSONContent } from '@tiptap/core'
|
||||
import type { DOMOutputSpec, Mark, Node as PMNode } from '@tiptap/pm/model'
|
||||
import React from 'react'
|
||||
|
||||
import { renderJSONContentToReactElement } from '../../json/react/react.js'
|
||||
import type { TiptapStaticRendererOptions } from '../../json/renderer.js'
|
||||
import type { StaticEditorOptions } from '../extensionRenderer.js'
|
||||
import { applyStaticEditorOptionsToExtensions, renderToElement } from '../extensionRenderer.js'
|
||||
|
||||
const HTML_ATTRIBUTE_TO_REACT_ATTRIBUTE: Record<string, string> = {
|
||||
colspan: 'colSpan',
|
||||
rowspan: 'rowSpan',
|
||||
}
|
||||
|
||||
/**
|
||||
* This function maps the attributes of a node or mark to HTML attributes
|
||||
* @param attrs The attributes to map
|
||||
* @param key The key to use for the React element
|
||||
* @returns The mapped HTML attributes as an object
|
||||
*/
|
||||
export function mapAttrsToHTMLAttributes(
|
||||
attrs?: Record<string, any>,
|
||||
key?: string,
|
||||
): Record<string, any> {
|
||||
if (!attrs) {
|
||||
return { key }
|
||||
}
|
||||
return Object.entries(attrs).reduce(
|
||||
(acc, [name, value]) => {
|
||||
if (name === 'class') {
|
||||
return Object.assign(acc, { className: value })
|
||||
}
|
||||
|
||||
// React expects styles to be a object
|
||||
// so we need to convert it from string to object
|
||||
if (name === 'style' && typeof value === 'string') {
|
||||
const styleObject: Record<string, string> = {}
|
||||
value.split(';').forEach(style => {
|
||||
const [styleKey, val] = style.split(':')
|
||||
if (styleKey && val) {
|
||||
// we need to turn the key into camelCase
|
||||
const camelCaseKey = styleKey.trim().replace(/-([a-z])/g, g => g[1].toUpperCase())
|
||||
styleObject[camelCaseKey] = val.trim()
|
||||
}
|
||||
})
|
||||
|
||||
return Object.assign(acc, { style: styleObject })
|
||||
}
|
||||
|
||||
const reactName = HTML_ATTRIBUTE_TO_REACT_ATTRIBUTE[name] ?? name
|
||||
|
||||
return Object.assign(acc, { [reactName]: value })
|
||||
},
|
||||
{ key },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a DOMOutputSpec and return a function that can render it to a React element
|
||||
* @param content The DOMOutputSpec to convert to a React element
|
||||
* @returns A function that can render the DOMOutputSpec to a React element
|
||||
*/
|
||||
export function domOutputSpecToReactElement(
|
||||
content: DOMOutputSpec,
|
||||
key = 0,
|
||||
): (children?: React.ReactNode) => React.ReactNode {
|
||||
if (typeof content === 'string') {
|
||||
return () => content
|
||||
}
|
||||
if (typeof content === 'object' && 'length' in content) {
|
||||
// oxlint-disable-next-line prefer-const
|
||||
let [tag, attrs, children, ...rest] = content as DOMOutputSpecArray
|
||||
const parts = tag.split(' ')
|
||||
|
||||
if (parts.length > 1) {
|
||||
tag = parts[1]
|
||||
if (attrs === undefined) {
|
||||
attrs = {
|
||||
xmlns: parts[0],
|
||||
}
|
||||
}
|
||||
if (attrs === 0) {
|
||||
attrs = {
|
||||
xmlns: parts[0],
|
||||
}
|
||||
children = 0
|
||||
}
|
||||
if (typeof attrs === 'object') {
|
||||
attrs = Object.assign(attrs, { xmlns: parts[0] })
|
||||
}
|
||||
}
|
||||
|
||||
if (attrs === undefined) {
|
||||
return () => React.createElement(tag, mapAttrsToHTMLAttributes(undefined, key.toString()))
|
||||
}
|
||||
if (attrs === 0) {
|
||||
return child =>
|
||||
React.createElement(tag, mapAttrsToHTMLAttributes(undefined, key.toString()), child)
|
||||
}
|
||||
if (typeof attrs === 'object') {
|
||||
if (Array.isArray(attrs)) {
|
||||
if (children === undefined) {
|
||||
return child =>
|
||||
React.createElement(
|
||||
tag,
|
||||
mapAttrsToHTMLAttributes(undefined, key.toString()),
|
||||
domOutputSpecToReactElement(attrs as DOMOutputSpecArray, key++)(child),
|
||||
)
|
||||
}
|
||||
if (children === 0) {
|
||||
return child =>
|
||||
React.createElement(
|
||||
tag,
|
||||
mapAttrsToHTMLAttributes(undefined, key.toString()),
|
||||
domOutputSpecToReactElement(attrs as DOMOutputSpecArray, key++)(child),
|
||||
)
|
||||
}
|
||||
return child =>
|
||||
React.createElement(
|
||||
tag,
|
||||
mapAttrsToHTMLAttributes(undefined, key.toString()),
|
||||
domOutputSpecToReactElement(attrs as DOMOutputSpecArray)(child),
|
||||
[children]
|
||||
.concat(rest)
|
||||
.map(outputSpec => domOutputSpecToReactElement(outputSpec, key++)(child)),
|
||||
)
|
||||
}
|
||||
if (children === undefined) {
|
||||
return () => React.createElement(tag, mapAttrsToHTMLAttributes(attrs, key.toString()))
|
||||
}
|
||||
if (children === 0) {
|
||||
return child =>
|
||||
React.createElement(tag, mapAttrsToHTMLAttributes(attrs, key.toString()), child)
|
||||
}
|
||||
|
||||
return child =>
|
||||
React.createElement(
|
||||
tag,
|
||||
mapAttrsToHTMLAttributes(attrs, key.toString()),
|
||||
[children]
|
||||
.concat(rest)
|
||||
.map(outputSpec => domOutputSpecToReactElement(outputSpec, key++)(child)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO support DOM elements? How to handle them?
|
||||
throw new Error(
|
||||
'[tiptap error]: Unsupported DomOutputSpec type, check the `renderHTML` method output or implement a node mapping',
|
||||
{
|
||||
cause: content,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will statically render a Prosemirror Node to a React component using the given extensions.
|
||||
*
|
||||
* Limitations: see `renderToHTMLString` — extensions that mutate the document
|
||||
* via plugins/onCreate (UniqueID, TableOfContents) need to be pre-processed.
|
||||
*
|
||||
* @param content The content to render to a React component
|
||||
* @param extensions The extensions to use for rendering
|
||||
* @param staticEditorOptions Optional editor-level options that affect rendered output — mirrors a subset of `EditorOptions`.
|
||||
* @param options The options to use for rendering
|
||||
* @returns The React element that represents the rendered content
|
||||
*/
|
||||
export function renderToReactElement({
|
||||
content,
|
||||
extensions,
|
||||
staticEditorOptions,
|
||||
options,
|
||||
}: {
|
||||
content: PMNode | JSONContent
|
||||
extensions: Extensions
|
||||
staticEditorOptions?: StaticEditorOptions
|
||||
options?: Partial<TiptapStaticRendererOptions<React.ReactNode, Mark, PMNode>>
|
||||
}): React.ReactNode {
|
||||
return renderToElement<React.ReactNode>({
|
||||
renderer: renderJSONContentToReactElement,
|
||||
domOutputSpecToElement: domOutputSpecToReactElement,
|
||||
mapDefinedTypes: {
|
||||
// Map a doc node to concatenated children
|
||||
doc: ({ children }) => React.createElement(React.Fragment, {}, children),
|
||||
// Map a text node to its text content
|
||||
text: ({ node }) => node.text ?? '',
|
||||
},
|
||||
content,
|
||||
extensions: applyStaticEditorOptionsToExtensions(extensions, staticEditorOptions),
|
||||
options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import Bold from '@tiptap/extension-bold'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Heading from '@tiptap/extension-heading'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { renderToHTMLString } from '@tiptap/static-renderer/pm/html-string'
|
||||
import { renderToMarkdown } from '@tiptap/static-renderer/pm/markdown'
|
||||
import { renderToReactElement } from '@tiptap/static-renderer/pm/react'
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
const extensions = [Document, Paragraph, Text, Bold, Heading]
|
||||
|
||||
// `calloutBox` is not in the schema above.
|
||||
const docWithUnknownNode = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'calloutBox', attrs: { variant: 'info' }, content: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
}
|
||||
|
||||
// `colorMark` is not in the schema above.
|
||||
const docWithUnknownMark = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'hi', marks: [{ type: 'colorMark', attrs: {} }] }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('static renderer: unhandledNode / unhandledMark for unknown schema types', () => {
|
||||
it('react: routes an unknown node to unhandledNode instead of throwing', () => {
|
||||
const view = render(
|
||||
renderToReactElement({
|
||||
content: docWithUnknownNode,
|
||||
extensions,
|
||||
options: {
|
||||
unhandledNode: () => React.createElement('div', { 'data-unhandled-node': 'true' }),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(view.container.querySelector('[data-unhandled-node]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('react: routes an unknown mark to unhandledMark instead of throwing', () => {
|
||||
const view = render(
|
||||
renderToReactElement({
|
||||
content: docWithUnknownMark,
|
||||
extensions,
|
||||
options: {
|
||||
unhandledMark: ({ children }) => React.createElement('mark', {}, children),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(view.container.querySelector('mark')?.textContent).toBe('hi')
|
||||
})
|
||||
|
||||
it('react: still throws for an unknown node when no fallback is provided', () => {
|
||||
expect(() => renderToReactElement({ content: docWithUnknownNode, extensions })).toThrow(
|
||||
/Unknown node type: calloutBox/,
|
||||
)
|
||||
})
|
||||
|
||||
it('react: re-throws the original error when the unknown node has no matching fallback', () => {
|
||||
// Only `unhandledMark` is provided, but the unknown type is a node — the
|
||||
// clear "Unknown node type" error must still surface instead of falling back.
|
||||
expect(() =>
|
||||
renderToReactElement({
|
||||
content: docWithUnknownNode,
|
||||
extensions,
|
||||
options: { unhandledMark: ({ children }) => React.createElement('mark', {}, children) },
|
||||
}),
|
||||
).toThrow(/Unknown node type: calloutBox/)
|
||||
})
|
||||
|
||||
it('does not mask non-unknown-type schema errors even when a fallback is set', () => {
|
||||
const malformed = {
|
||||
type: 'doc',
|
||||
// text node without a `text` string — all types are known, so this is a
|
||||
// structural error, not an unknown-type one. It must still throw.
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text' }] }],
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
renderToReactElement({
|
||||
content: malformed,
|
||||
extensions,
|
||||
options: { unhandledNode: () => React.createElement('div') },
|
||||
}),
|
||||
).toThrow(/Invalid text node in JSON/)
|
||||
})
|
||||
|
||||
it('html-string: routes an unknown node to unhandledNode instead of throwing', () => {
|
||||
const html = renderToHTMLString({
|
||||
content: docWithUnknownNode,
|
||||
extensions,
|
||||
options: { unhandledNode: () => '<div data-unhandled-node></div>' },
|
||||
})
|
||||
|
||||
expect(html).toContain('data-unhandled-node')
|
||||
})
|
||||
|
||||
it('preserves the happy path for fully-known content', () => {
|
||||
const html = renderToHTMLString({
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [{ type: 'heading', content: [{ type: 'text', text: 'x' }] }],
|
||||
},
|
||||
extensions,
|
||||
})
|
||||
|
||||
expect(html).toBe('<h1>x</h1>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('static renderer: unknown types preserve known nodes (sentinel substitution)', () => {
|
||||
it('hands the original type and attributes to unhandledNode', () => {
|
||||
let seenType: string | undefined
|
||||
let seenVariant: unknown
|
||||
|
||||
render(
|
||||
renderToReactElement({
|
||||
content: docWithUnknownNode,
|
||||
extensions,
|
||||
options: {
|
||||
unhandledNode: ({ node }) => {
|
||||
seenType = node.type.name
|
||||
seenVariant = node.attrs.variant
|
||||
return React.createElement('div')
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(seenType).toBe('calloutBox')
|
||||
expect(seenVariant).toBe('info')
|
||||
})
|
||||
|
||||
it('hands the original type to unhandledMark', () => {
|
||||
let seenType: string | undefined
|
||||
|
||||
render(
|
||||
renderToReactElement({
|
||||
content: docWithUnknownMark,
|
||||
extensions,
|
||||
options: {
|
||||
unhandledMark: ({ mark, children }) => {
|
||||
seenType = mark.type.name
|
||||
return React.createElement('mark', {}, children)
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(seenType).toBe('colorMark')
|
||||
})
|
||||
|
||||
it('renders known nodes with their schema attribute defaults alongside an unknown node', () => {
|
||||
// The heading carries no explicit `level`; it must still resolve to its
|
||||
// default (<h1>) rather than losing the attribute and crashing.
|
||||
const view = render(
|
||||
renderToReactElement({
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', content: [{ type: 'text', text: 'title' }] },
|
||||
{ type: 'calloutBox', content: [{ type: 'text', text: 'x' }] },
|
||||
],
|
||||
},
|
||||
extensions,
|
||||
options: { unhandledNode: () => React.createElement('aside') },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(view.container.querySelector('h1')?.textContent).toBe('title')
|
||||
expect(view.container.querySelector('aside')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps markdown output correct for known nodes when an unknown node is present', () => {
|
||||
const markdown = renderToMarkdown({
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] }],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'b' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'calloutBox', content: [{ type: 'text', text: 'note' }] },
|
||||
],
|
||||
},
|
||||
extensions: [StarterKit],
|
||||
options: { unhandledNode: ({ children }) => `\n[callout]${children}\n` },
|
||||
})
|
||||
|
||||
expect(markdown).toContain('- a')
|
||||
expect(markdown).toContain('- b')
|
||||
expect(markdown).toContain('[callout]')
|
||||
})
|
||||
|
||||
it('routes an unknown mark to unhandledMark in markdown output', () => {
|
||||
let seenMark: string | undefined
|
||||
|
||||
const markdown = renderToMarkdown({
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'hi', marks: [{ type: 'colorMark', attrs: {} }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
extensions: [StarterKit],
|
||||
options: {
|
||||
unhandledMark: ({ mark, children }) => {
|
||||
seenMark = mark.type.name
|
||||
return `[[${children}]]`
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(markdown).toContain('[[hi]]')
|
||||
expect(seenMark).toBe('colorMark')
|
||||
})
|
||||
|
||||
it('still throws a structural error when an unknown-with-fallback type is also present', () => {
|
||||
// A genuinely malformed known node (text without `text`) must not be masked
|
||||
// just because the document also contains a handled unknown node.
|
||||
expect(() =>
|
||||
render(
|
||||
renderToReactElement({
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text' }] },
|
||||
{ type: 'calloutBox', content: [{ type: 'text', text: 'x' }] },
|
||||
],
|
||||
},
|
||||
extensions,
|
||||
options: { unhandledNode: () => React.createElement('div') },
|
||||
}),
|
||||
),
|
||||
).toThrow(/Invalid text node in JSON/)
|
||||
})
|
||||
|
||||
it('restores the original JSON when a fallback calls node.toJSON()', () => {
|
||||
let dumped: JSONContent | undefined
|
||||
|
||||
render(
|
||||
renderToReactElement({
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'calloutBox',
|
||||
attrs: { variant: 'info' },
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'deep', marks: [{ type: 'colorMark', attrs: { c: 1 } }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
extensions,
|
||||
options: {
|
||||
unhandledNode: ({ node }) => {
|
||||
dumped = node.toJSON()
|
||||
return React.createElement('div')
|
||||
},
|
||||
unhandledMark: ({ children }) => React.createElement('mark', {}, children),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// The unknown node serializes back to its original type/attrs, with no
|
||||
// placeholder leakage — including a nested unknown mark deeper in the tree.
|
||||
expect(dumped?.type).toBe('calloutBox')
|
||||
expect(dumped?.attrs).toEqual({ variant: 'info' })
|
||||
const text = dumped?.content?.[0]?.content?.[0]
|
||||
expect(text?.text).toBe('deep')
|
||||
expect(text?.marks?.[0]).toEqual({ type: 'colorMark', attrs: { c: 1 } })
|
||||
})
|
||||
})
|
||||
26
packages/static-renderer/vite.config.ts
Normal file
26
packages/static-renderer/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
import { tsupCompatibleExtensions } from '../../pack.config.mjs'
|
||||
|
||||
const entries = [
|
||||
'src/json/html-string/index.ts',
|
||||
'src/json/react/index.ts',
|
||||
'src/json/renderer.ts',
|
||||
'src/pm/react/index.ts',
|
||||
'src/pm/html-string/index.ts',
|
||||
'src/pm/markdown/index.ts',
|
||||
'src/index.ts',
|
||||
]
|
||||
|
||||
export default defineConfig({
|
||||
pack: entries.map(entry => ({
|
||||
entry: [entry],
|
||||
tsconfig: '../../tsconfig.build.json',
|
||||
outDir: `dist${entry.replace('src', '').split('/').slice(0, -1).join('/')}`,
|
||||
dts: { sourcemap: true },
|
||||
sourcemap: true,
|
||||
target: 'es2019',
|
||||
format: ['esm', 'cjs'],
|
||||
deps: { neverBundle: [/^[^./]/] },
|
||||
outExtensions: tsupCompatibleExtensions,
|
||||
})),
|
||||
})
|
||||
Reference in New Issue
Block a user