diff --git a/packages/static-renderer/CHANGELOG.md b/packages/static-renderer/CHANGELOG.md new file mode 100644 index 0000000000..420e6f23d0 --- /dev/null +++ b/packages/static-renderer/CHANGELOG.md @@ -0,0 +1 @@ +# Change Log diff --git a/packages/static-renderer/README.md b/packages/static-renderer/README.md new file mode 100644 index 0000000000..492aa23b4b --- /dev/null +++ b/packages/static-renderer/README.md @@ -0,0 +1,18 @@ +# @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). + +## License + +Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md). diff --git a/packages/static-renderer/package.json b/packages/static-renderer/package.json new file mode 100644 index 0000000000..70c723d867 --- /dev/null +++ b/packages/static-renderer/package.json @@ -0,0 +1,45 @@ +{ + "name": "@tiptap/static-renderer", + "description": "statically render Tiptap JSON", + "version": "2.6.6", + "homepage": "https://tiptap.dev", + "keywords": [ + "tiptap", + "tiptap static renderer", + "tiptap react renderer" + ], + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "exports": { + ".": { + "types": "./dist/packages/static-renderer/src/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "main": "dist/index.cjs", + "module": "dist/index.js", + "umd": "dist/index.umd.js", + "types": "dist/packages/static-renderer/src/index.d.ts", + "type": "module", + "files": [ + "src", + "dist" + ], + "dependencies": { + "@tiptap/core": "^2.6.6", + "@tiptap/pm": "^2.6.6" + }, + "repository": { + "type": "git", + "url": "https://github.com/ueberdosis/tiptap", + "directory": "packages/static-renderer" + }, + "scripts": { + "clean": "rm -rf dist", + "build": "npm run clean && rollup -c" + } +} diff --git a/packages/static-renderer/rollup.config.js b/packages/static-renderer/rollup.config.js new file mode 100644 index 0000000000..cb8e994031 --- /dev/null +++ b/packages/static-renderer/rollup.config.js @@ -0,0 +1,5 @@ +import { baseConfig } from '@tiptap-shared/rollup-config' + +import pkg from './package.json' assert { type: 'json' } + +export default baseConfig({ input: 'src/index.ts', pkg }) diff --git a/packages/static-renderer/src/base.ts b/packages/static-renderer/src/base.ts new file mode 100644 index 0000000000..250db116aa --- /dev/null +++ b/packages/static-renderer/src/base.ts @@ -0,0 +1,199 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { MarkType, NodeType } from './types' + +/** + * Props for a node renderer + */ +export type NodeProps = { + node: TNodeType; + children?: TChildren; +}; + +/** + * Props for a mark renderer + */ +export type MarkProps = { + mark: TMarkType; + children?: TChildren; +}; + +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 } = MarkType, + /** + * 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 }; + } = NodeType, + /** + * A node renderer is a function that takes a node and its children and returns the rendered output + */ + TNodeRender extends ( + ctx: NodeProps + ) => TReturnType = ( + ctx: NodeProps + ) => TReturnType, + /** + * A mark renderer is a function that takes a mark and its children and returns the rendered output + */ + TMarkRender extends ( + ctx: MarkProps + ) => TReturnType = ( + ctx: MarkProps + ) => TReturnType, +> = { + /** + * Mapping of node types to react components + */ + nodeMapping: Record; + /** + * Mapping of mark types to react components + */ + markMapping: Record; + /** + * Component to render if a node type is not handled + */ + unhandledNode?: TNodeRender; + /** + * Component to render if a mark type is not handled + */ + unhandledMark?: 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 } } = MarkType, + /** + * 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 }; + } = NodeType, + /** + * A node renderer is a function that takes a node and its children and returns the rendered output + */ + TNodeRender extends ( + ctx: NodeProps + ) => TReturnType = ( + ctx: NodeProps + ) => TReturnType, + /** + * A mark renderer is a function that takes a mark and its children and returns the rendered output + */ + TMarkRender extends ( + ctx: MarkProps + ) => TReturnType = ( + ctx: MarkProps + ) => TReturnType, +>( + /** + * The function that actually renders the component + */ + renderComponent: ( + ctx: + | { + component: TNodeRender; + props: NodeProps; + } + | { + component: TMarkRender; + props: MarkProps; + } + ) => 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, + }: { + /** + * Tiptap JSON content to render + */ + content: TNodeType; + }): TReturnType { + // recursively render child content nodes + const children: TReturnType[] = [] + + if (content.content) { + content.content.forEach(child => { + children.push( + renderContent({ + content: child, + }), + ) + }) + } + 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, 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, node: undefined, children: acc }, + }) + }, nodeContent) + : nodeContent + + return markedContent + } +} diff --git a/packages/static-renderer/src/helpers.tsx b/packages/static-renderer/src/helpers.tsx new file mode 100644 index 0000000000..9c63064ec1 --- /dev/null +++ b/packages/static-renderer/src/helpers.tsx @@ -0,0 +1,103 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + ExtensionAttribute, + getAttributesFromExtensions, + mergeAttributes, + resolveExtensions, +} from '@tiptap/core' +import { TextAlign } from '@tiptap/extension-text-align' +import { TextStyle } from '@tiptap/extension-text-style' +import StarterKit from '@tiptap/starter-kit' + +import type { MarkType, NodeType } from './types' + +/** + * 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 { + const nodeOrMarkAttributes = nodeOrMark.attrs + + if (!nodeOrMarkAttributes) { + return {} + } + + return extensionAttributes + .filter(item => { + if (item.type !== nodeOrMark.type) { + 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) +} + +const extensionAttributes = getAttributesFromExtensions( + resolveExtensions([ + StarterKit, + TextAlign.configure({ + types: ['paragraph', 'heading'], + }), + TextStyle, + ]), +) +const attributes = getAttributes( + { + type: 'heading', + attrs: { + textAlign: 'right', + }, + content: [ + { + type: 'text', + text: 'hello world', + }, + ], + }, + extensionAttributes, +) + +console.log(attributes) diff --git a/packages/static-renderer/src/json/react.tsx b/packages/static-renderer/src/json/react.tsx new file mode 100644 index 0000000000..77ab74d258 --- /dev/null +++ b/packages/static-renderer/src/json/react.tsx @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import React from 'react' + +import { + NodeProps, + TiptapStaticRenderer, + TiptapStaticRendererOptions, +} from '../base.js' +import { NodeType } from '../types.js' + +export const reactRenderer = ( + options: TiptapStaticRendererOptions, +) => { + let key = 0 + + return TiptapStaticRenderer( + ({ component, props: { children, ...props } }) => { + key += 1 + return React.createElement( + component as React.FC, + Object.assign(props, { key }), + ([] as React.ReactNode[]).concat(children), + ) + }, + options, + ) +} + +const fn = reactRenderer({ + nodeMapping: { + text({ node }) { + return node.text ?? null + }, + heading({ + node, + children, + }: NodeProps, React.ReactNode>) { + const level = node.attrs.level + const hTag = `h${level}` + + return React.createElement(hTag, node.attrs, children) + }, + }, + markMapping: {}, +}) + +console.log( + fn({ + content: { + type: 'heading', + content: [ + { + type: 'text', + text: 'hello world', + marks: [], + }, + ], + attrs: { level: 2 }, + }, + }), +) diff --git a/packages/static-renderer/src/json/string.ts b/packages/static-renderer/src/json/string.ts new file mode 100644 index 0000000000..58310b1143 --- /dev/null +++ b/packages/static-renderer/src/json/string.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { TiptapStaticRenderer, TiptapStaticRendererOptions } from '../base.js' + +export const stringRenderer = ( + options: TiptapStaticRendererOptions, +) => { + return TiptapStaticRenderer(ctx => { + return ctx.component(ctx.props as any) + }, options) +} + +const fnAgain = stringRenderer({ + nodeMapping: { + text({ node }) { + return node.text! + }, + heading({ node, children }) { + const level = node.attrs?.level + const attrs = Object.entries(node.attrs || {}) + .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .join(' ') + + return `${([] as string[]) + .concat(children || '') + .filter(Boolean) + .join('\n')}` + }, + }, + markMapping: {}, +}) + +console.log( + fnAgain({ + content: { + type: 'heading', + content: [ + { + type: 'text', + text: 'hello world', + marks: [], + }, + ], + attrs: { level: 2 }, + }, + }), +) diff --git a/packages/static-renderer/src/pm/react.tsx b/packages/static-renderer/src/pm/react.tsx new file mode 100644 index 0000000000..b12516aa03 --- /dev/null +++ b/packages/static-renderer/src/pm/react.tsx @@ -0,0 +1,432 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + Extensions, + getAttributesFromExtensions, + getExtensionField, + getSchema, + MarkConfig, + NodeConfig, + splitExtensions, +} from '@tiptap/core' +import type { DOMOutputSpec, Mark, Node } from '@tiptap/pm/model' +import StarterKit from '@tiptap/starter-kit' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { TiptapStaticRenderer, TiptapStaticRendererOptions } from '../base.js' +import { getHTMLAttributes, resolveExtensions } from '../helpers.js' +import { DOMOutputSpecArray } from '../types.js' + +export function reactRenderer( + options: TiptapStaticRendererOptions, +) { + let key = 0 + + return TiptapStaticRenderer( + ({ component, props: { children, ...props } }) => { + key += 1 + return React.createElement( + component as React.FC, + Object.assign(props, { key }), + ([] as React.ReactNode[]).concat(children), + ) + }, + options, + ) +} + +function domToElement( + content: DOMOutputSpec, +): (children?: React.ReactNode) => React.ReactNode { + if (typeof content === 'string') { + return () => content + } + if (typeof content === 'object' && 'length' in content) { + const [tag, attrs, children, ...rest] = content as DOMOutputSpecArray + + if (attrs === undefined) { + return () => React.createElement(tag) + } + if (attrs === 0) { + return child => React.createElement(tag, undefined, child) + } + if (typeof attrs === 'object') { + if (Array.isArray(attrs)) { + if (children === undefined) { + return child => React.createElement( + tag, + undefined, + domToElement(attrs as DOMOutputSpecArray)(child), + ) + } + if (children === 0) { + return child => React.createElement( + tag, + undefined, + domToElement(attrs as DOMOutputSpecArray)(child), + ) + } + return child => React.createElement( + tag, + undefined, + domToElement(attrs as DOMOutputSpecArray)(child), + [children].concat(rest).map(a => domToElement(a)(child)), + ) + } + if (children === undefined) { + return () => React.createElement(tag, attrs) + } + if (children === 0) { + return child => React.createElement(tag, attrs, child) + } + + return child => React.createElement( + tag, + attrs, + [children].concat(rest).map(a => domToElement(a)(child)), + ) + + } + } + + // TODO support DOM? + throw new Error('Unsupported DOM type', { cause: content }) +} + +export function generateMappings( + extensions: Extensions, +): TiptapStaticRendererOptions { + extensions = resolveExtensions(extensions) + const extensionAttributes = getAttributesFromExtensions(extensions) + const { nodeExtensions, markExtensions } = splitExtensions(extensions) + + return { + nodeMapping: Object.fromEntries( + nodeExtensions.map(extension => { + if (extension.name === 'doc') { + // Skip any work for the doc extension + return [ + extension.name, + ({ children }) => { + return children + }, + ] + } + if (extension.name === 'text') { + // Skip any work for the text extension + return ['text', ({ node }) => node.text!] + } + + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + parent: extension.parent, + } + + const renderToHTML = getExtensionField( + extension, + 'renderHTML', + context, + ) + + if (!renderToHTML) { + return [ + extension.name, + () => { + throw new Error( + `Node ${extension.name} cannot be rendered, it is missing a "renderToHTML" method`, + ) + }, + ] + } + + return [ + extension.name, + ({ node, children }) => { + return domToElement( + renderToHTML({ + node, + HTMLAttributes: getHTMLAttributes(node, extensionAttributes), + }), + )(children) + }, + ] + }), + ), + markMapping: Object.fromEntries( + markExtensions.map(extension => { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + parent: extension.parent, + } + + const renderToHTML = getExtensionField( + extension, + 'renderHTML', + context, + ) + + if (!renderToHTML) { + return [ + extension.name, + () => { + throw new Error( + `Node ${extension.name} cannot be rendered, it is missing a "renderToHTML" method`, + ) + }, + ] + } + + return [ + extension.name, + ({ mark, children }) => { + return domToElement( + renderToHTML({ + mark, + HTMLAttributes: getHTMLAttributes(mark, extensionAttributes), + }), + )(children) + }, + ] + }), + ), + } +} + +const extensions = [StarterKit] +const fn = reactRenderer( + // { + // nodeMapping: { + // text({ node }) { + // return node.text!; + // }, + // heading({ node, children }) { + // return

{children}

; + // }, + // }, + // markMapping: {}, + // } + generateMappings(extensions), +) + +const schema = getSchema([StarterKit]) + +console.log( + renderToStaticMarkup( + fn({ + content: schema.nodeFromJSON( + // { + // type: "heading", + // content: [ + // { + // type: "text", + // text: "hello world", + // marks: [], + // }, + // ], + // attrs: { level: 2 }, + // } + { + type: 'doc', + from: 0, + to: 574, + content: [ + { + type: 'heading', + from: 0, + to: 11, + attrs: { + level: 2, + }, + content: [ + { + type: 'text', + from: 1, + to: 10, + text: 'Hi there,', + }, + ], + }, + { + type: 'paragraph', + from: 11, + to: 169, + content: [ + { + type: 'text', + from: 12, + to: 22, + text: 'this is a ', + }, + { + type: 'text', + from: 22, + to: 27, + marks: [ + { + type: 'italic', + }, + ], + text: 'basic', + }, + { + type: 'text', + from: 27, + to: 39, + text: ' example of ', + }, + { + type: 'text', + from: 39, + to: 45, + marks: [ + { + type: 'bold', + }, + ], + text: 'Tiptap', + }, + { + type: 'text', + from: 45, + to: 168, + text: '. Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists:', + }, + ], + }, + { + type: 'bulletList', + from: 169, + to: 230, + content: [ + { + type: 'listItem', + from: 170, + to: 205, + attrs: { + color: '', + }, + content: [ + { + type: 'paragraph', + from: 171, + to: 204, + content: [ + { + type: 'text', + from: 172, + to: 203, + text: 'That’s a bullet list with one …', + }, + ], + }, + ], + }, + { + type: 'listItem', + from: 205, + to: 229, + attrs: { + color: '', + }, + content: [ + { + type: 'paragraph', + from: 206, + to: 228, + content: [ + { + type: 'text', + from: 207, + to: 227, + text: '… or two list items.', + }, + ], + }, + ], + }, + ], + }, + { + type: 'paragraph', + from: 230, + to: 326, + content: [ + { + type: 'text', + from: 231, + to: 325, + text: 'Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block:', + }, + ], + }, + { + type: 'codeBlock', + from: 326, + to: 353, + attrs: { + language: 'css', + }, + content: [ + { + type: 'text', + from: 327, + to: 352, + text: 'body {\n display: none;\n}', + }, + ], + }, + { + type: 'paragraph', + from: 353, + to: 522, + content: [ + { + type: 'text', + from: 354, + to: 521, + text: 'I know, I know, this is impressive. It’s only the tip of the iceberg though. Give it a try and click a little bit around. Don’t forget to check the other examples too.', + }, + ], + }, + { + type: 'blockquote', + from: 522, + to: 572, + content: [ + { + type: 'paragraph', + from: 523, + to: 571, + content: [ + { + type: 'text', + from: 524, + to: 564, + text: 'Wow, that’s amazing. Good work, boy! 👏 ', + }, + { + type: 'hardBreak', + from: 564, + to: 565, + }, + { + type: 'text', + from: 565, + to: 570, + text: '— Mom', + }, + ], + }, + ], + }, + ], + }, + ), + }), + ), +) diff --git a/packages/static-renderer/src/pm/string.ts b/packages/static-renderer/src/pm/string.ts new file mode 100644 index 0000000000..71d3aae318 --- /dev/null +++ b/packages/static-renderer/src/pm/string.ts @@ -0,0 +1,433 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + Extensions, + getAttributesFromExtensions, + getExtensionField, + getSchema, + MarkConfig, + NodeConfig, + resolveExtensions, + splitExtensions, +} from '@tiptap/core' +import type { DOMOutputSpec, Mark, Node } from '@tiptap/pm/model' +import StarterKit from '@tiptap/starter-kit' + +import { TiptapStaticRenderer, TiptapStaticRendererOptions } from '../base.js' +import { getHTMLAttributes } from '../helpers.js' +import { DOMOutputSpecArray } from '../types.js' + +export const stringRenderer = ( + options: TiptapStaticRendererOptions, +) => { + return TiptapStaticRenderer(ctx => { + return ctx.component(ctx.props as any) + }, options) +} + +function serializeAttrs(attrs: Record): string { + return Object.entries(attrs) + .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .join(' ') +} +function serializeChildren(children?: string | string[]): string { + return ([] as string[]) + .concat(children || '') + .filter(Boolean) + .join('\n') +} + +function domToString( + content: DOMOutputSpec, +): (children?: string | string[]) => string { + if (typeof content === 'string') { + return () => content + } + if (typeof content === 'object' && 'length' in content) { + const [tag, attrs, children, ...rest] = content as DOMOutputSpecArray + + if (attrs === undefined) { + return () => `<${tag} />` + } + if (attrs === 0) { + return child => `<${tag}>${serializeChildren(child)}` + } + if (typeof attrs === 'object') { + if (Array.isArray(attrs)) { + if (children === undefined) { + return child => `<${tag}>${domToString(attrs as DOMOutputSpecArray)( + child, + )}` + } + if (children === 0) { + return child => `<${tag}>${domToString(attrs as DOMOutputSpecArray)( + child, + )}` + } + return child => `<${tag}>${domToString(attrs as DOMOutputSpecArray)(child)}${[ + children, + ] + .concat(rest) + .map(a => domToString(a)(child))}` + } + if (children === undefined) { + return () => `<${tag} ${serializeAttrs(attrs)} />` + } + if (children === 0) { + return child => `<${tag} ${serializeAttrs(attrs)}>${serializeChildren( + child, + )}` + } + + return child => `<${tag} ${serializeAttrs(attrs)}>${[children] + .concat(rest) + .map(a => domToString(a)(child))}` + + } + } + + // TODO support DOM? + throw new Error('Unsupported DOM type', { cause: content }) +} + +export function generateMappings( + extensions: Extensions, +): TiptapStaticRendererOptions { + extensions = resolveExtensions(extensions) + const extensionAttributes = getAttributesFromExtensions(extensions) + const { nodeExtensions, markExtensions } = splitExtensions(extensions) + + return { + nodeMapping: Object.fromEntries( + nodeExtensions.map(extension => { + if (extension.name === 'doc') { + // Skip any work for the doc extension + return [ + extension.name, + ({ children }) => { + return serializeChildren(children) + }, + ] + } + if (extension.name === 'text') { + // Skip any work for the text extension + return ['text', ({ node }) => node.text!] + } + + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + parent: extension.parent, + } + + const renderToHTML = getExtensionField( + extension, + 'renderHTML', + context, + ) + + if (!renderToHTML) { + return [ + extension.name, + () => { + throw new Error( + `Node ${extension.name} cannot be rendered, it is missing a "renderToHTML" method`, + ) + }, + ] + } + + return [ + extension.name, + ({ node, children }) => { + return domToString( + renderToHTML({ + node, + HTMLAttributes: getHTMLAttributes(node, extensionAttributes), + }), + )(children) + }, + ] + }), + ), + markMapping: Object.fromEntries( + markExtensions.map(extension => { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + parent: extension.parent, + } + + const renderToHTML = getExtensionField( + extension, + 'renderHTML', + context, + ) + + if (!renderToHTML) { + return [ + extension.name, + () => { + throw new Error( + `Node ${extension.name} cannot be rendered, it is missing a "renderToHTML" method`, + ) + }, + ] + } + + return [ + extension.name, + ({ mark, children }) => { + return domToString( + renderToHTML({ + mark, + HTMLAttributes: getHTMLAttributes(mark, extensionAttributes), + }), + )(children) + }, + ] + }), + ), + } +} + +const extensions = [StarterKit] +const fn = stringRenderer( + // { + // nodeMapping: { + // text({ node }) { + // return node.text!; + // }, + // heading({ node, children }) { + // const level = node.attrs.level; + // const attrs = Object.entries(node.attrs || {}) + // .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + // .join(" "); + // return `${([] as string[]) + // .concat(children || "") + // .filter(Boolean) + // .join("\n")}`; + // }, + // }, + // markMapping: {}, + // } + generateMappings(extensions), +) + +const schema = getSchema(extensions) + +console.log( + fn({ + content: schema.nodeFromJSON( + // { + // type: "heading", + // content: [ + // { + // type: "text", + // text: "hello world", + // marks: [], + // }, + // ], + // attrs: { level: 2 }, + // } + { + type: 'doc', + from: 0, + to: 574, + content: [ + { + type: 'heading', + from: 0, + to: 11, + attrs: { + level: 2, + }, + content: [ + { + type: 'text', + from: 1, + to: 10, + text: 'Hi there,', + }, + ], + }, + { + type: 'paragraph', + from: 11, + to: 169, + content: [ + { + type: 'text', + from: 12, + to: 22, + text: 'this is a ', + }, + { + type: 'text', + from: 22, + to: 27, + marks: [ + { + type: 'italic', + }, + ], + text: 'basic', + }, + { + type: 'text', + from: 27, + to: 39, + text: ' example of ', + }, + { + type: 'text', + from: 39, + to: 45, + marks: [ + { + type: 'bold', + }, + ], + text: 'Tiptap', + }, + { + type: 'text', + from: 45, + to: 168, + text: '. Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists:', + }, + ], + }, + { + type: 'bulletList', + from: 169, + to: 230, + content: [ + { + type: 'listItem', + from: 170, + to: 205, + attrs: { + color: '', + }, + content: [ + { + type: 'paragraph', + from: 171, + to: 204, + content: [ + { + type: 'text', + from: 172, + to: 203, + text: 'That’s a bullet list with one …', + }, + ], + }, + ], + }, + { + type: 'listItem', + from: 205, + to: 229, + attrs: { + color: '', + }, + content: [ + { + type: 'paragraph', + from: 206, + to: 228, + content: [ + { + type: 'text', + from: 207, + to: 227, + text: '… or two list items.', + }, + ], + }, + ], + }, + ], + }, + { + type: 'paragraph', + from: 230, + to: 326, + content: [ + { + type: 'text', + from: 231, + to: 325, + text: 'Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block:', + }, + ], + }, + { + type: 'codeBlock', + from: 326, + to: 353, + attrs: { + language: 'css', + }, + content: [ + { + type: 'text', + from: 327, + to: 352, + text: 'body {\n display: none;\n}', + }, + ], + }, + { + type: 'paragraph', + from: 353, + to: 522, + content: [ + { + type: 'text', + from: 354, + to: 521, + text: 'I know, I know, this is impressive. It’s only the tip of the iceberg though. Give it a try and click a little bit around. Don’t forget to check the other examples too.', + }, + ], + }, + { + type: 'blockquote', + from: 522, + to: 572, + content: [ + { + type: 'paragraph', + from: 523, + to: 571, + content: [ + { + type: 'text', + from: 524, + to: 564, + text: 'Wow, that’s amazing. Good work, boy! 👏 ', + }, + { + type: 'hardBreak', + from: 564, + to: 565, + }, + { + type: 'text', + from: 565, + to: 570, + text: '— Mom', + }, + ], + }, + ], + }, + ], + }, + ), + }), +) diff --git a/packages/static-renderer/src/types.ts b/packages/static-renderer/src/types.ts new file mode 100644 index 0000000000..174414a8c9 --- /dev/null +++ b/packages/static-renderer/src/types.ts @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export type MarkType< + Type extends string = any, + Attributes extends undefined | Record = any, +> = { + type: Type; + attrs: Attributes; +}; + +export type NodeType< + Type extends string = any, + Attributes extends undefined | Record = any, + NodeMarkType extends MarkType = any, + Content extends NodeType[] = any, +> = { + type: Type; + attrs: Attributes; + content?: Content; + marks?: NodeMarkType[]; + text?: string; +}; + +export type DocumentType< + TNodeAttributes extends Record = Record, + TContentType extends NodeType[] = NodeType[], +> = NodeType<'doc', TNodeAttributes, never, TContentType>; + +export type TextType = { + type: 'text'; + text: string; + marks: TMarkType[]; +}; + +export type DOMOutputSpecArray = + | [string] + | [string, Record] + | [string, 0] + | [string, Record, 0] + | [string, Record, DOMOutputSpecArray | 0] + | [string, DOMOutputSpecArray];