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

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'