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:
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'
|
||||
Reference in New Issue
Block a user