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

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
# @tiptap/static-renderer
[![Version](https://img.shields.io/npm/v/@tiptap/static-renderer.svg?label=version)](https://www.npmjs.com/package/@tiptap/static-renderer)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/static-renderer.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/static-renderer.svg)](https://www.npmjs.com/package/@tiptap/static-renderer)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](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).

View 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"
}
}

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

View 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'

View File

@@ -0,0 +1,2 @@
export * from '../renderer.js'
export * from './string.js'

View 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&quot;&gt;&lt;img src=x onerror=alert(document.cookie)&gt;" title="Tom &amp; &quot;Jerry&quot;"',
)
})
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 />')
})
})

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/**
* 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, '&quot;')
}
/**
* 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('')
}

View File

@@ -0,0 +1,2 @@
export * from '../renderer.js'
export * from './react.js'

View 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,
)
}

View 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
}
}

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

View 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>&lt;img src=x onerror=alert(document.cookie)&gt; Tom &amp; 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=&quot;&gt;&lt;img src=x onerror=alert(document.cookie)&gt;"',
)
})
it('escapes string DOM output specs as text content', () => {
const html = domOutputSpecToHTMLString('<img src=x onerror=alert(document.cookie)>')()
expect(html).toBe('&lt;img src=x onerror=alert(document.cookie)&gt;')
})
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="[^"]+"/)
})
})

View 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,
})
}

View File

@@ -0,0 +1,2 @@
export * from '../extensionRenderer.js'
export * from './html-string.js'

View File

@@ -0,0 +1,2 @@
export * from '../extensionRenderer.js'
export * from './markdown.js'

View 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')
})
})

View 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 `![${node.attrs.alt}](${node.attrs.src})`
},
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,
},
},
})
}

View File

@@ -0,0 +1,2 @@
export * from '../extensionRenderer.js'
export * from './react.js'

View 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')
})
})

View 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,
})
}

View File

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

View 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,
})),
})