From 91af03781914e185c214582ee9a03aa629200b35 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 20 Aug 2024 17:30:37 +0200 Subject: [PATCH 01/31] feat: allowing specifying the content of ReacNodeViewContent via a React Context --- packages/react/src/NodeViewContent.tsx | 19 ++++++++++--------- packages/react/src/useReactNodeView.ts | 17 ++++++++++++++--- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/react/src/NodeViewContent.tsx b/packages/react/src/NodeViewContent.tsx index f8f26c5953c..81b63bd48ec 100644 --- a/packages/react/src/NodeViewContent.tsx +++ b/packages/react/src/NodeViewContent.tsx @@ -1,15 +1,14 @@ -import React from 'react' +import React, { ComponentProps } from 'react' import { useReactNodeView } from './useReactNodeView.js' -export interface NodeViewContentProps { - [key: string]: any, - as?: React.ElementType, -} +export type NodeViewContentProps = { + // eslint-disable-next-line no-undef + as?: NoInfer; +} & ComponentProps -export const NodeViewContent: React.FC = props => { - const Tag = props.as || 'div' - const { nodeViewContentRef } = useReactNodeView() +export function NodeViewContent({ as: Tag = 'div', ...props }: NodeViewContentProps) { + const { nodeViewContentRef, nodeViewContentChildren } = useReactNodeView() return ( // @ts-ignore @@ -21,6 +20,8 @@ export const NodeViewContent: React.FC = props => { whiteSpace: 'pre-wrap', ...props.style, }} - /> + > + {nodeViewContentChildren} + ) } diff --git a/packages/react/src/useReactNodeView.ts b/packages/react/src/useReactNodeView.ts index 6127ea4a7f4..27978f7b88c 100644 --- a/packages/react/src/useReactNodeView.ts +++ b/packages/react/src/useReactNodeView.ts @@ -1,12 +1,23 @@ -import { createContext, useContext } from 'react' +import { createContext, ReactNode, useContext } from 'react' export interface ReactNodeViewContextProps { onDragStart: (event: DragEvent) => void, nodeViewContentRef: (element: HTMLElement | null) => void, + /** + * This allows you to add children into the NodeViewContent component. + * This is useful when statically rendering the content of a node view. + */ + nodeViewContentChildren: ReactNode, } -export const ReactNodeViewContext = createContext>({ - onDragStart: undefined, +export const ReactNodeViewContext = createContext({ + onDragStart: () => { + // no-op + }, + nodeViewContentChildren: undefined, + nodeViewContentRef: () => { + // no-op + }, }) export const useReactNodeView = () => useContext(ReactNodeViewContext) From 0e11ceda63830ed95e94e8aee95bf7ff04e2f355 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 23 Aug 2024 18:24:28 +0200 Subject: [PATCH 02/31] refactor: export `resolveExtensions` function --- packages/core/src/ExtensionManager.ts | 102 +++--------------- .../core/src/helpers/flattenExtensions.ts | 34 ++++++ packages/core/src/helpers/getSchema.ts | 4 +- packages/core/src/helpers/index.ts | 3 + .../core/src/helpers/resolveExtensions.ts | 25 +++++ packages/core/src/helpers/sortExtensions.ts | 26 +++++ packages/core/src/utilities/findDuplicates.ts | 5 +- 7 files changed, 111 insertions(+), 88 deletions(-) create mode 100644 packages/core/src/helpers/flattenExtensions.ts create mode 100644 packages/core/src/helpers/resolveExtensions.ts create mode 100644 packages/core/src/helpers/sortExtensions.ts diff --git a/packages/core/src/ExtensionManager.ts b/packages/core/src/ExtensionManager.ts index a0fda44be2a..19668523f09 100644 --- a/packages/core/src/ExtensionManager.ts +++ b/packages/core/src/ExtensionManager.ts @@ -4,21 +4,24 @@ import { Plugin } from '@tiptap/pm/state' import { NodeViewConstructor } from '@tiptap/pm/view' import type { Editor } from './Editor.js' -import { getAttributesFromExtensions } from './helpers/getAttributesFromExtensions.js' -import { getExtensionField } from './helpers/getExtensionField.js' -import { getNodeType } from './helpers/getNodeType.js' -import { getRenderedAttributes } from './helpers/getRenderedAttributes.js' -import { getSchemaByResolvedExtensions } from './helpers/getSchemaByResolvedExtensions.js' -import { getSchemaTypeByName } from './helpers/getSchemaTypeByName.js' -import { isExtensionRulesEnabled } from './helpers/isExtensionRulesEnabled.js' -import { splitExtensions } from './helpers/splitExtensions.js' +import { + flattenExtensions, getAttributesFromExtensions, + getExtensionField, + getNodeType, + getRenderedAttributes, + getSchemaByResolvedExtensions, + getSchemaTypeByName, + isExtensionRulesEnabled, + resolveExtensions, + sortExtensions, + splitExtensions, +} from './helpers/index.js' import type { NodeConfig } from './index.js' import { InputRule, inputRulesPlugin } from './InputRule.js' import { Mark } from './Mark.js' import { PasteRule, pasteRulesPlugin } from './PasteRule.js' import { AnyConfig, Extensions, RawCommands } from './types.js' import { callOrReturn } from './utilities/callOrReturn.js' -import { findDuplicates } from './utilities/findDuplicates.js' export class ExtensionManager { editor: Editor @@ -31,87 +34,16 @@ export class ExtensionManager { constructor(extensions: Extensions, editor: Editor) { this.editor = editor - this.extensions = ExtensionManager.resolve(extensions) + this.extensions = resolveExtensions(extensions) this.schema = getSchemaByResolvedExtensions(this.extensions, editor) this.setupExtensions() } - /** - * Returns a flattened and sorted extension list while - * also checking for duplicated extensions and warns the user. - * @param extensions An array of Tiptap extensions - * @returns An flattened and sorted array of Tiptap extensions - */ - static resolve(extensions: Extensions): Extensions { - const resolvedExtensions = ExtensionManager.sort(ExtensionManager.flatten(extensions)) - const duplicatedNames = findDuplicates(resolvedExtensions.map(extension => extension.name)) - - if (duplicatedNames.length) { - console.warn( - `[tiptap warn]: Duplicate extension names found: [${duplicatedNames - .map(item => `'${item}'`) - .join(', ')}]. This can lead to issues.`, - ) - } - - return resolvedExtensions - } - - /** - * Create a flattened array of extensions by traversing the `addExtensions` field. - * @param extensions An array of Tiptap extensions - * @returns A flattened array of Tiptap extensions - */ - static flatten(extensions: Extensions): Extensions { - return ( - extensions - .map(extension => { - const context = { - name: extension.name, - options: extension.options, - storage: extension.storage, - } - - const addExtensions = getExtensionField( - extension, - 'addExtensions', - context, - ) - - if (addExtensions) { - return [extension, ...this.flatten(addExtensions())] - } - - return extension - }) - // `Infinity` will break TypeScript so we set a number that is probably high enough - .flat(10) - ) - } - - /** - * Sort extensions by priority. - * @param extensions An array of Tiptap extensions - * @returns A sorted array of Tiptap extensions by priority - */ - static sort(extensions: Extensions): Extensions { - const defaultPriority = 100 - - return extensions.sort((a, b) => { - const priorityA = getExtensionField(a, 'priority') || defaultPriority - const priorityB = getExtensionField(b, 'priority') || defaultPriority - - if (priorityA > priorityB) { - return -1 - } + static resolve = resolveExtensions - if (priorityA < priorityB) { - return 1 - } + static sort = sortExtensions - return 0 - }) - } + static flatten = flattenExtensions /** * Get all commands from the extensions. @@ -156,7 +88,7 @@ export class ExtensionManager { // so it feels more natural to run plugins at the end of an array first. // That’s why we have to reverse the `extensions` array and sort again // based on the `priority` option. - const extensions = ExtensionManager.sort([...this.extensions].reverse()) + const extensions = sortExtensions([...this.extensions].reverse()) const inputRules: InputRule[] = [] const pasteRules: PasteRule[] = [] diff --git a/packages/core/src/helpers/flattenExtensions.ts b/packages/core/src/helpers/flattenExtensions.ts new file mode 100644 index 00000000000..2c7bbcc1f16 --- /dev/null +++ b/packages/core/src/helpers/flattenExtensions.ts @@ -0,0 +1,34 @@ +import { AnyConfig, Extensions } from '../types.js' +import { getExtensionField } from './getExtensionField.js' + +/** + * Create a flattened array of extensions by traversing the `addExtensions` field. + * @param extensions An array of Tiptap extensions + * @returns A flattened array of Tiptap extensions + */ +export function flattenExtensions(extensions: Extensions): Extensions { + return ( + extensions + .map(extension => { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + } + + const addExtensions = getExtensionField( + extension, + 'addExtensions', + context, + ) + + if (addExtensions) { + return [extension, ...flattenExtensions(addExtensions())] + } + + return extension + }) + // `Infinity` will break TypeScript so we set a number that is probably high enough + .flat(10) + ) +} diff --git a/packages/core/src/helpers/getSchema.ts b/packages/core/src/helpers/getSchema.ts index f9644f9f6e5..9f52423b5d8 100644 --- a/packages/core/src/helpers/getSchema.ts +++ b/packages/core/src/helpers/getSchema.ts @@ -1,12 +1,12 @@ import { Schema } from '@tiptap/pm/model' import { Editor } from '../Editor.js' -import { ExtensionManager } from '../ExtensionManager.js' import { Extensions } from '../types.js' import { getSchemaByResolvedExtensions } from './getSchemaByResolvedExtensions.js' +import { resolveExtensions } from './resolveExtensions.js' export function getSchema(extensions: Extensions, editor?: Editor): Schema { - const resolvedExtensions = ExtensionManager.resolve(extensions) + const resolvedExtensions = resolveExtensions(extensions) return getSchemaByResolvedExtensions(resolvedExtensions, editor) } diff --git a/packages/core/src/helpers/index.ts b/packages/core/src/helpers/index.ts index b74a8ceba8d..5ce5583b29a 100644 --- a/packages/core/src/helpers/index.ts +++ b/packages/core/src/helpers/index.ts @@ -7,6 +7,7 @@ export * from './findChildren.js' export * from './findChildrenInRange.js' export * from './findParentNode.js' export * from './findParentNodeClosestToPos.js' +export * from './flattenExtensions.js' export * from './generateHTML.js' export * from './generateJSON.js' export * from './generateText.js' @@ -45,7 +46,9 @@ export * from './isNodeEmpty.js' export * from './isNodeSelection.js' export * from './isTextSelection.js' export * from './posToDOMRect.js' +export * from './resolveExtensions.js' export * from './resolveFocusPosition.js' export * from './rewriteUnknownContent.js' export * from './selectionToInsertionEnd.js' +export * from './sortExtensions.js' export * from './splitExtensions.js' diff --git a/packages/core/src/helpers/resolveExtensions.ts b/packages/core/src/helpers/resolveExtensions.ts new file mode 100644 index 00000000000..bb1eccaf5ad --- /dev/null +++ b/packages/core/src/helpers/resolveExtensions.ts @@ -0,0 +1,25 @@ +import { Extensions } from '../types.js' +import { findDuplicates } from '../utilities/findDuplicates.js' +import { flattenExtensions } from './flattenExtensions.js' +import { sortExtensions } from './sortExtensions.js' + +/** + * Returns a flattened and sorted extension list while + * also checking for duplicated extensions and warns the user. + * @param extensions An array of Tiptap extensions + * @returns An flattened and sorted array of Tiptap extensions + */ +export function resolveExtensions(extensions: Extensions): Extensions { + const resolvedExtensions = sortExtensions(flattenExtensions(extensions)) + const duplicatedNames = findDuplicates(resolvedExtensions.map(extension => extension.name)) + + if (duplicatedNames.length) { + console.warn( + `[tiptap warn]: Duplicate extension names found: [${duplicatedNames + .map(item => `'${item}'`) + .join(', ')}]. This can lead to issues.`, + ) + } + + return resolvedExtensions +} diff --git a/packages/core/src/helpers/sortExtensions.ts b/packages/core/src/helpers/sortExtensions.ts new file mode 100644 index 00000000000..a5baa106a93 --- /dev/null +++ b/packages/core/src/helpers/sortExtensions.ts @@ -0,0 +1,26 @@ +import { AnyConfig, Extensions } from '../types.js' +import { getExtensionField } from './getExtensionField.js' + +/** + * Sort extensions by priority. + * @param extensions An array of Tiptap extensions + * @returns A sorted array of Tiptap extensions by priority + */ +export function sortExtensions(extensions: Extensions): Extensions { + const defaultPriority = 100 + + return extensions.sort((a, b) => { + const priorityA = getExtensionField(a, 'priority') || defaultPriority + const priorityB = getExtensionField(b, 'priority') || defaultPriority + + if (priorityA > priorityB) { + return -1 + } + + if (priorityA < priorityB) { + return 1 + } + + return 0 + }) +} diff --git a/packages/core/src/utilities/findDuplicates.ts b/packages/core/src/utilities/findDuplicates.ts index a506e0d345b..17c59e73de6 100644 --- a/packages/core/src/utilities/findDuplicates.ts +++ b/packages/core/src/utilities/findDuplicates.ts @@ -1,4 +1,7 @@ -export function findDuplicates(items: any[]): any[] { +/** + * Find duplicates in an array. + */ +export function findDuplicates(items: T[]): T[] { const filtered = items.filter((el, index) => items.indexOf(el) !== index) return Array.from(new Set(filtered)) From 3c9a9e5d0a212f694791948f4b341db18b28a27d Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 23 Aug 2024 19:28:46 +0200 Subject: [PATCH 03/31] feat: first version of a static renderer --- packages/static-renderer/CHANGELOG.md | 1 + packages/static-renderer/README.md | 18 + packages/static-renderer/package.json | 45 ++ packages/static-renderer/rollup.config.js | 5 + packages/static-renderer/src/base.ts | 199 +++++++++ packages/static-renderer/src/helpers.tsx | 103 +++++ packages/static-renderer/src/json/react.tsx | 62 +++ packages/static-renderer/src/json/string.ts | 46 +++ packages/static-renderer/src/pm/react.tsx | 432 +++++++++++++++++++ packages/static-renderer/src/pm/string.ts | 433 ++++++++++++++++++++ packages/static-renderer/src/types.ts | 41 ++ 11 files changed, 1385 insertions(+) create mode 100644 packages/static-renderer/CHANGELOG.md create mode 100644 packages/static-renderer/README.md create mode 100644 packages/static-renderer/package.json create mode 100644 packages/static-renderer/rollup.config.js create mode 100644 packages/static-renderer/src/base.ts create mode 100644 packages/static-renderer/src/helpers.tsx create mode 100644 packages/static-renderer/src/json/react.tsx create mode 100644 packages/static-renderer/src/json/string.ts create mode 100644 packages/static-renderer/src/pm/react.tsx create mode 100644 packages/static-renderer/src/pm/string.ts create mode 100644 packages/static-renderer/src/types.ts diff --git a/packages/static-renderer/CHANGELOG.md b/packages/static-renderer/CHANGELOG.md new file mode 100644 index 00000000000..420e6f23d0e --- /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 00000000000..492aa23b4b3 --- /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 00000000000..70c723d867f --- /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 00000000000..cb8e994031b --- /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 00000000000..250db116aa1 --- /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 00000000000..9c63064ec1d --- /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 00000000000..77ab74d258c --- /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 00000000000..58310b1143c --- /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 00000000000..b12516aa03a --- /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 00000000000..71d3aae318d --- /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 00000000000..174414a8c96 --- /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]; From 3958f5213fe9209edc4c0dfa0e14b883f47465ce Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 27 Aug 2024 11:26:17 +0200 Subject: [PATCH 04/31] feat(static-renderer): firm up the API and have it render to HTML String & React Elements --- .../static-renderer/src/helpers.example.ts | 35 ++ .../src/{helpers.tsx => helpers.ts} | 42 +- .../static-renderer/src/json/react.example.ts | 45 ++ packages/static-renderer/src/json/react.tsx | 65 +-- .../src/{base.ts => json/renderer.ts} | 71 ++- .../src/json/string.example.ts | 46 ++ packages/static-renderer/src/json/string.ts | 56 +-- .../src/pm/extensionRenderer.ts | 216 ++++++++ .../src/pm/html-string.example.ts | 225 +++++++++ .../static-renderer/src/pm/html-string.ts | 121 +++++ .../src/pm/markdown.example.ts | 270 ++++++++++ .../static-renderer/src/pm/react.example.tsx | 227 +++++++++ packages/static-renderer/src/pm/react.tsx | 467 ++++-------------- packages/static-renderer/src/pm/string.ts | 433 ---------------- packages/static-renderer/src/types.ts | 16 + 15 files changed, 1379 insertions(+), 956 deletions(-) create mode 100644 packages/static-renderer/src/helpers.example.ts rename packages/static-renderer/src/{helpers.tsx => helpers.ts} (69%) create mode 100644 packages/static-renderer/src/json/react.example.ts rename packages/static-renderer/src/{base.ts => json/renderer.ts} (80%) create mode 100644 packages/static-renderer/src/json/string.example.ts create mode 100644 packages/static-renderer/src/pm/extensionRenderer.ts create mode 100644 packages/static-renderer/src/pm/html-string.example.ts create mode 100644 packages/static-renderer/src/pm/html-string.ts create mode 100644 packages/static-renderer/src/pm/markdown.example.ts create mode 100644 packages/static-renderer/src/pm/react.example.tsx delete mode 100644 packages/static-renderer/src/pm/string.ts diff --git a/packages/static-renderer/src/helpers.example.ts b/packages/static-renderer/src/helpers.example.ts new file mode 100644 index 00000000000..7a8fff9b6a6 --- /dev/null +++ b/packages/static-renderer/src/helpers.example.ts @@ -0,0 +1,35 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { getAttributesFromExtensions, 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 { getAttributes } from './helpers.js' + +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, +) + +// eslint-disable-next-line no-console +console.log(attributes) diff --git a/packages/static-renderer/src/helpers.tsx b/packages/static-renderer/src/helpers.ts similarity index 69% rename from packages/static-renderer/src/helpers.tsx rename to packages/static-renderer/src/helpers.ts index 9c63064ec1d..b1fa2e20e73 100644 --- a/packages/static-renderer/src/helpers.tsx +++ b/packages/static-renderer/src/helpers.ts @@ -1,13 +1,5 @@ /* 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 { ExtensionAttribute, mergeAttributes } from '@tiptap/core' import type { MarkType, NodeType } from './types' @@ -57,10 +49,7 @@ export function getAttributes( } ) }) - .reduce( - (attributes, attribute) => mergeAttributes(attributes, attribute), - {}, - ) + .reduce((attributes, attribute) => mergeAttributes(attributes, attribute), {}) } /** @@ -74,30 +63,3 @@ export function getHTMLAttributes( ) { 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.example.ts b/packages/static-renderer/src/json/react.example.ts new file mode 100644 index 00000000000..b31606553eb --- /dev/null +++ b/packages/static-renderer/src/json/react.example.ts @@ -0,0 +1,45 @@ +import React from 'react' + +import { NodeType } from '../types.js' +import { renderJSONContentToReactElement } from './react.js' +import { NodeProps } from './renderer.js' + +/** + * This example demonstrates how to render a JSON representation of a node to a React element + * It does so without including Prosemirror or Tiptap, it is the lightest possible way to render JSON content + * But, since it doesn't include Prosemirror or Tiptap, it cannot automatically render marks or nodes for you. + * If you need that, you should use the `renderToReactElement` from `@tiptap/static-renderer` + * + * You have complete control over the rendering process. And can replace how each Node/Mark is rendered. + */ + +// eslint-disable-next-line no-console +console.log(renderJSONContentToReactElement({ + 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: {}, +})({ + content: { + type: 'heading', + content: [ + { + type: 'text', + text: 'hello world', + marks: [], + }, + ], + attrs: { level: 2 }, + }, +})) diff --git a/packages/static-renderer/src/json/react.tsx b/packages/static-renderer/src/json/react.tsx index 77ab74d258c..49aecb57dd9 100644 --- a/packages/static-renderer/src/json/react.tsx +++ b/packages/static-renderer/src/json/react.tsx @@ -2,61 +2,34 @@ import React from 'react' -import { - NodeProps, - TiptapStaticRenderer, - TiptapStaticRendererOptions, -} from '../base.js' -import { NodeType } from '../types.js' +import { MarkType, NodeType } from '../types.js' +import { TiptapStaticRenderer, TiptapStaticRendererOptions } from './renderer.js' -export const reactRenderer = ( - options: TiptapStaticRendererOptions, -) => { +export function renderJSONContentToReactElement< + /** + * 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, +>(options: TiptapStaticRendererOptions) { let key = 0 - return TiptapStaticRenderer( + return TiptapStaticRenderer( ({ component, props: { children, ...props } }) => { - key += 1 return React.createElement( component as React.FC, - Object.assign(props, { key }), + // eslint-disable-next-line no-plusplus + Object.assign(props, { key: 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/base.ts b/packages/static-renderer/src/json/renderer.ts similarity index 80% rename from packages/static-renderer/src/base.ts rename to packages/static-renderer/src/json/renderer.ts index 250db116aa1..b4ac95a61a1 100644 --- a/packages/static-renderer/src/base.ts +++ b/packages/static-renderer/src/json/renderer.ts @@ -1,12 +1,35 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { MarkType, NodeType } from './types' +import type { MarkType, NodeType } from '../types' /** * Props for a node renderer */ export type NodeProps = { + /** + * 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; }; /** @@ -147,24 +170,18 @@ cb: (node: TNodeType) => void) => void }; */ return function renderContent({ content, + parent, }: { /** * Tiptap JSON content to render */ content: TNodeType; + /** + * The parent node of the current node + */ + parent?: 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 @@ -174,7 +191,29 @@ cb: (node: TNodeType) => void) => void }; const nodeContent = renderComponent({ component: NodeHandler, - props: { node: content, children }, + 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 @@ -189,7 +228,11 @@ cb: (node: TNodeType) => void) => void }; return renderComponent({ component: MarkHandler, - props: { mark, node: undefined, children: acc }, + props: { + mark, + parent, + children: acc, + }, }) }, nodeContent) : nodeContent diff --git a/packages/static-renderer/src/json/string.example.ts b/packages/static-renderer/src/json/string.example.ts new file mode 100644 index 00000000000..2240b01aa92 --- /dev/null +++ b/packages/static-renderer/src/json/string.example.ts @@ -0,0 +1,46 @@ +import { renderJSONContentToString } from './string.js' + +/** + * This example demonstrates how to render a JSON representation of a node to a string + * It does so without including Prosemirror or Tiptap, it is the lightest possible way to render JSON content + * But, since it doesn't include Prosemirror or Tiptap, it cannot automatically render marks or nodes for you. + * If you need that, you should use the `renderToHTMLString` from `@tiptap/static-renderer` + * + * You have complete control over the rendering process. And can replace how each Node/Mark is rendered. + */ + +// eslint-disable-next-line no-console +console.log( + renderJSONContentToString({ + nodeMapping: { + text({ node }) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + 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: {}, + })({ + 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 index 58310b1143c..e4aab8f4b3e 100644 --- a/packages/static-renderer/src/json/string.ts +++ b/packages/static-renderer/src/json/string.ts @@ -1,46 +1,22 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { TiptapStaticRenderer, TiptapStaticRendererOptions } from '../base.js' +import { MarkType, NodeType } from '../types.js' +import { TiptapStaticRenderer, TiptapStaticRendererOptions } from './renderer.js' -export const stringRenderer = ( - options: TiptapStaticRendererOptions, -) => { +export function renderJSONContentToString< +/** + * 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, +>(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/extensionRenderer.ts b/packages/static-renderer/src/pm/extensionRenderer.ts new file mode 100644 index 00000000000..e8a5a408e7c --- /dev/null +++ b/packages/static-renderer/src/pm/extensionRenderer.ts @@ -0,0 +1,216 @@ +/* eslint-disable no-plusplus */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + ExtensionAttribute, + Extensions, + getAttributesFromExtensions, + getExtensionField, + getSchemaByResolvedExtensions, + JSONContent, + Mark as MarkExtension, + MarkConfig, + Node as NodeExtension, + NodeConfig, + resolveExtensions, + splitExtensions, +} from '@tiptap/core' +import { DOMOutputSpec, Mark, Node } from '@tiptap/pm/model' + +import { getHTMLAttributes } from '../helpers.js' +import { MarkProps, NodeProps, TiptapStaticRendererOptions } from '../json/renderer.js' + +export type DomOutputSpecToElement = (content: DOMOutputSpec) => (children?: T | T[]) => T; + +/** + * 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( + domOutputSpecToElement: DomOutputSpecToElement, + extension: NodeExtension, + extensionAttributes: ExtensionAttribute[], +): [string, (props: NodeProps) => T] { + + 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( + `[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( + domOutputSpecToElement: DomOutputSpecToElement, + extension: MarkExtension, + extensionAttributes: ExtensionAttribute[], +): [string, (props: MarkProps) => T] { + 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 }) => { + 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 }, + ) + } + }, + ] +} + +/** + * 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({ + renderer, + domOutputSpecToElement, + mapDefinedTypes, + content, + extensions, + options, +}: { + renderer: (options: TiptapStaticRendererOptions) => (ctx: { content: Node }) => T; + domOutputSpecToElement: DomOutputSpecToElement; + mapDefinedTypes: { + doc: (props: NodeProps) => T; + text: (props: NodeProps) => T; + }; + content: Node | JSONContent; + extensions: Extensions; + options?: Partial>; +}): T { + // get all extensions in order & split them into nodes and marks + extensions = resolveExtensions(extensions) + const extensionAttributes = getAttributesFromExtensions(extensions) + const { nodeExtensions, markExtensions } = splitExtensions(extensions) + + if (!(content instanceof Node)) { + content = Node.fromJSON(getSchemaByResolvedExtensions(extensions), content) + } + + 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 (options?.nodeMapping) { + return !(e.name in options.nodeMapping) + } + return true + }) + .map(nodeExtension => mapNodeExtensionToReactNode( + domOutputSpecToElement, + nodeExtension, + extensionAttributes, + )), + ), + ...mapDefinedTypes, + ...options?.nodeMapping, + }, + markMapping: { + ...Object.fromEntries( + markExtensions + .filter(e => { + // No need to generate mappings for marks that are already mapped + if (options?.markMapping) { + return !(e.name in options.markMapping) + } + return true + }).map(mark => mapMarkExtensionToReactNode(domOutputSpecToElement, mark, extensionAttributes)), + ), + ...options?.markMapping, + }, + })({ content }) +} diff --git a/packages/static-renderer/src/pm/html-string.example.ts b/packages/static-renderer/src/pm/html-string.example.ts new file mode 100644 index 00000000000..fdd695bb542 --- /dev/null +++ b/packages/static-renderer/src/pm/html-string.example.ts @@ -0,0 +1,225 @@ +import StarterKit from '@tiptap/starter-kit' + +import { renderToHTMLString, serializeAttrsToHTMLString, serializeChildrenToHTMLString } from './html-string.js' + +/** + * This example demonstrates how to render a Prosemirror Node (or JSON Content) to an HTML string. + * It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method. + * This can be useful if you want to render content to HTML without having an actual editor instance. + * + * You have complete control over the rendering process. And can replace how each Node/Mark is rendered. + */ + +// eslint-disable-next-line no-console +console.log( + renderToHTMLString({ + extensions: [StarterKit], + options: { + nodeMapping: { + heading({ node, children }) { + const level = node.attrs.level + + return `THIS IS AN EXAMPLE OF CUSTOM HTML STRING RENDERING${serializeChildrenToHTMLString(children)}` + }, + }, + markMapping: {}, + }, + content: + { + 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/html-string.ts b/packages/static-renderer/src/pm/html-string.ts new file mode 100644 index 00000000000..ca9fefb9c3f --- /dev/null +++ b/packages/static-renderer/src/pm/html-string.ts @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Extensions, JSONContent } from '@tiptap/core' +import type { DOMOutputSpec, Mark, Node } from '@tiptap/pm/model' + +import { TiptapStaticRendererOptions } from '../json/renderer.js' +import { renderJSONContentToString } from '../json/string.js' +import type { DOMOutputSpecArray } from '../types.js' +import { renderToElement } from './extensionRenderer.js' + +/** + * 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 { + const output = Object.entries(attrs) + .map(([key, value]) => `${key}=${JSON.stringify(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('') +} + +/** + * 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 () => 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}>${serializeChildrenToHTMLString(child)}` + } + if (typeof attrs === 'object') { + if (Array.isArray(attrs)) { + if (children === undefined) { + return child => `<${tag}>${domOutputSpecToHTMLString(attrs as DOMOutputSpecArray)(child)}` + } + if (children === 0) { + return child => `<${tag}>${domOutputSpecToHTMLString(attrs as DOMOutputSpecArray)(child)}` + } + return child => `<${tag}>${domOutputSpecToHTMLString(attrs as DOMOutputSpecArray)(child)}${[children] + .concat(rest) + .map(a => domOutputSpecToHTMLString(a)(child))}` + } + if (children === undefined) { + return () => `<${tag}${serializeAttrsToHTMLString(attrs)}/>` + } + if (children === 0) { + return child => `<${tag}${serializeAttrsToHTMLString(attrs)}>${serializeChildrenToHTMLString( + child, + )}` + } + + return child => `<${tag}${serializeAttrsToHTMLString(attrs)}>${[children] + .concat(rest) + .map(a => domOutputSpecToHTMLString(a)(child)) + .join('')}` + } + } + + // TODO support DOM elements? How to handle them? + throw new Error( + '[tiptap error]: Unsupported DomOutputSpec type, check the `renderHTML` method output', + { + cause: content, + }, + ) +} + +/** + * This function will statically render a Prosemirror Node to HTML using the provided extensions and options + * @param content The content to render to HTML + * @param extensions The extensions to use for rendering + * @param options The options to use for rendering + * @returns The rendered HTML string + */ +export function renderToHTMLString({ + content, + extensions, + options, +}: { + content: Node | JSONContent; + extensions: Extensions; + options?: Partial>; +}): string { + return renderToElement({ + 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 }) => node.text ?? '', + }, + content, + extensions, + options, + }) +} diff --git a/packages/static-renderer/src/pm/markdown.example.ts b/packages/static-renderer/src/pm/markdown.example.ts new file mode 100644 index 00000000000..c1dad7fb680 --- /dev/null +++ b/packages/static-renderer/src/pm/markdown.example.ts @@ -0,0 +1,270 @@ +import { JSONContent } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' + +import { renderToHTMLString, serializeChildrenToHTMLString } from './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. + */ + +const renderToMarkdown = ({ content }: { content: JSONContent | Node }) => renderToHTMLString({ + content, + extensions: [StarterKit], + 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 = 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 `${new Array(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')}` + }, + hardBreak() { + return '\n' + }, + }, + markMapping: { + bold({ children }) { + return `**${serializeChildrenToHTMLString(children)}**` + }, + italic({ children }) { + return `*${serializeChildrenToHTMLString(children)}*` + }, + }, + }, +}) + +// eslint-disable-next-line no-console +console.log( + renderToMarkdown({ + content: { + 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/react.example.tsx b/packages/static-renderer/src/pm/react.example.tsx new file mode 100644 index 00000000000..50c5707983f --- /dev/null +++ b/packages/static-renderer/src/pm/react.example.tsx @@ -0,0 +1,227 @@ +/* eslint-disable no-plusplus */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import StarterKit from '@tiptap/starter-kit' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { renderToReactElement } from './react.js' + +/** + * This example demonstrates how to render a Prosemirror Node (or JSON Content) to a React Element. + * It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method. + * This can be useful if you want to render content to React without having an actual editor instance. + * + * You have complete control over the rendering process. And can replace how each Node/Mark is rendered. + */ + +const Element = renderToReactElement({ + extensions: [StarterKit], + options: { + nodeMapping: { + heading({ node, children }) { + return

THIS IS AN EXAMPLE OF CUSTOM RENDERING{children}

+ }, + }, + markMapping: {}, + }, + content: { + 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', + }, + ], + }, + ], + }, + ], + }, +}) + +// eslint-disable-next-line no-console +console.log(renderToStaticMarkup(Element)) diff --git a/packages/static-renderer/src/pm/react.tsx b/packages/static-renderer/src/pm/react.tsx index b12516aa03a..3f3783985d3 100644 --- a/packages/static-renderer/src/pm/react.tsx +++ b/packages/static-renderer/src/pm/react.tsx @@ -1,43 +1,42 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import { - Extensions, - getAttributesFromExtensions, - getExtensionField, - getSchema, - MarkConfig, - NodeConfig, - splitExtensions, -} from '@tiptap/core' +/* eslint-disable no-plusplus, @typescript-eslint/no-explicit-any */ +import { Extensions, JSONContent } 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), - ) +import { renderJSONContentToReactElement } from '../json/react.js' +import { TiptapStaticRendererOptions } from '../json/renderer.js' +import type { DOMOutputSpecArray } from '../types.js' +import { renderToElement } from './extensionRenderer.js' + +/** + * 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 + */ +function mapAttrsToHTMLAttributes(attrs?: Record, key?: string): Record { + if (!attrs) { + return { key } + } + return Object.entries(attrs).reduce( + (acc, [name, value]) => { + if (name === 'class') { + return Object.assign(acc, { className: value }) + } + return Object.assign(acc, { [name]: value }) }, - options, + { key }, ) } -function domToElement( +/** + * 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 @@ -46,387 +45,89 @@ function domToElement( const [tag, attrs, children, ...rest] = content as DOMOutputSpecArray if (attrs === undefined) { - return () => React.createElement(tag) + return () => React.createElement(tag, mapAttrsToHTMLAttributes(undefined, key.toString())) } if (attrs === 0) { - return child => React.createElement(tag, undefined, child) + 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, - undefined, - domToElement(attrs as DOMOutputSpecArray)(child), + mapAttrsToHTMLAttributes(undefined, key.toString()), + domOutputSpecToReactElement(attrs as DOMOutputSpecArray, key++)(child), ) } if (children === 0) { return child => React.createElement( tag, - undefined, - domToElement(attrs as DOMOutputSpecArray)(child), + mapAttrsToHTMLAttributes(undefined, key.toString()), + domOutputSpecToReactElement(attrs as DOMOutputSpecArray, key++)(child), ) } return child => React.createElement( tag, - undefined, - domToElement(attrs as DOMOutputSpecArray)(child), - [children].concat(rest).map(a => domToElement(a)(child)), + 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, attrs) + return () => React.createElement(tag, mapAttrsToHTMLAttributes(attrs, key.toString())) } if (children === 0) { - return child => React.createElement(tag, attrs, child) + return child => React.createElement(tag, mapAttrsToHTMLAttributes(attrs, key.toString()), child) } return child => React.createElement( tag, - attrs, - [children].concat(rest).map(a => domToElement(a)(child)), + mapAttrsToHTMLAttributes(attrs, key.toString()), + [children] + .concat(rest) + .map(outputSpec => domOutputSpecToReactElement(outputSpec, key++)(child)), ) - } } - // TODO support DOM? - throw new Error('Unsupported DOM type', { cause: content }) + // TODO support DOM elements? How to handle them? + throw new Error( + '[tiptap error]: Unsupported DomOutputSpec type, check the `renderHTML` method output', + { + 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) - }, - ] - }), - ), - } +/** + * This function will statically render a Prosemirror Node to a React component using the given extensions + * @param content The content to render to a React component + * @param extensions The extensions to use for rendering + * @param options The options to use for rendering + * @returns The React element that represents the rendered content + */ +export function renderToReactElement({ + content, + extensions, + options, +}: { + content: Node | JSONContent; + extensions: Extensions; + options?: Partial>; +}): React.ReactNode { + return renderToElement({ + renderer: renderJSONContentToReactElement, + domOutputSpecToElement: domOutputSpecToReactElement, + mapDefinedTypes: { + // Map a doc node to concatenated children + doc: ({ children }) => <>{children}, + // Map a text node to its text content + text: ({ node }) => node.text ?? '', + }, + content, + extensions, + options, + }) } - -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 deleted file mode 100644 index 71d3aae318d..00000000000 --- a/packages/static-renderer/src/pm/string.ts +++ /dev/null @@ -1,433 +0,0 @@ -/* 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 index 174414a8c96..1b1352f1124 100644 --- a/packages/static-renderer/src/types.ts +++ b/packages/static-renderer/src/types.ts @@ -1,5 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * A mark type is either a JSON representation of a mark or a Prosemirror mark instance + */ export type MarkType< Type extends string = any, Attributes extends undefined | Record = any, @@ -8,6 +11,9 @@ export type MarkType< attrs: Attributes; }; +/** + * A node type is either a JSON representation of a node or a Prosemirror node instance + */ export type NodeType< Type extends string = any, Attributes extends undefined | Record = any, @@ -21,17 +27,27 @@ export type NodeType< text?: string; }; +/** + * A node type is either a JSON representation of a doc node or a Prosemirror doc node instance + */ export type DocumentType< TNodeAttributes extends Record = Record, TContentType extends NodeType[] = NodeType[], > = NodeType<'doc', TNodeAttributes, never, TContentType>; +/** + * A node type is either a JSON representation of a text node or a Prosemirror text node instance + */ export type TextType = { type: 'text'; text: string; marks: TMarkType[]; }; +/** + * Describes the output of a `renderHTML` function in prosemirror + * @see https://prosemirror.net/docs/ref/#model.DOMOutputSpec + */ export type DOMOutputSpecArray = | [string] | [string, Record] From a5ab9da304e687e88345a55ac1f4410f30800633 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 31 Dec 2024 11:19:58 +0100 Subject: [PATCH 05/31] chore: minor change --- package-lock.json | 60 +++++++++++++++++++ .../src/pm/markdown.example.ts | 2 +- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index a264faae788..42eeb16be6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5164,6 +5164,10 @@ "resolved": "packages/starter-kit", "link": true }, + "node_modules/@tiptap/static-renderer": { + "resolved": "packages/static-renderer", + "link": true + }, "node_modules/@tiptap/suggestion": { "resolved": "packages/suggestion", "link": true @@ -19835,6 +19839,62 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "packages/static-renderer": { + "name": "@tiptap/static-renderer", + "version": "2.6.6", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^2.6.6", + "@tiptap/pm": "^2.6.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "packages/static-renderer/node_modules/@tiptap/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.0.tgz", + "integrity": "sha512-0S3AWx6E2QqwdQqb6z0/q6zq2u9lA9oL3BLyAaITGSC9zt8OwjloS2k1zN6wLa9hp2rO0c0vDnWsTPeFaEaMdw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "packages/static-renderer/node_modules/@tiptap/pm": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.0.tgz", + "integrity": "sha512-4RU6bpODkMY+ZshzdRFcuUc5jWlMW82LWXR6UOsHK/X/Mav41ZFS0Cyf+hQM6gxxTB09YFIICmGpEpULb+/CuA==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.2.1", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.1", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "packages/suggestion": { "name": "@tiptap/suggestion", "version": "3.0.0-next.3", diff --git a/packages/static-renderer/src/pm/markdown.example.ts b/packages/static-renderer/src/pm/markdown.example.ts index c1dad7fb680..5e97521b329 100644 --- a/packages/static-renderer/src/pm/markdown.example.ts +++ b/packages/static-renderer/src/pm/markdown.example.ts @@ -24,7 +24,7 @@ const renderToMarkdown = ({ content }: { content: JSONContent | Node }) => rende return `- ${serializeChildrenToHTMLString(children).trim()}\n` } if (parent?.type.name === 'orderedList') { - let number = 1 + let number = parent.attrs.start || 1 parent.forEach((parentChild, _offset, index) => { if (node === parentChild) { From dbf3682b8f9e1c627ecdd2cbfca4b0e5d50596cd Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 27 Aug 2024 16:19:10 +0200 Subject: [PATCH 06/31] docs: more examples --- packages/react/src/NodeViewContent.tsx | 2 +- packages/react/src/useReactNodeView.ts | 24 +++++- .../static-renderer/src/pm/react.example.tsx | 85 ++++++++++++++++++- 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/packages/react/src/NodeViewContent.tsx b/packages/react/src/NodeViewContent.tsx index 81b63bd48ec..576a03da583 100644 --- a/packages/react/src/NodeViewContent.tsx +++ b/packages/react/src/NodeViewContent.tsx @@ -7,7 +7,7 @@ export type NodeViewContentProps({ as: Tag = 'div', ...props }: NodeViewContentProps) { +export function NodeViewContent({ as: Tag = 'div' as T, ...props }: NodeViewContentProps) { const { nodeViewContentRef, nodeViewContentChildren } = useReactNodeView() return ( diff --git a/packages/react/src/useReactNodeView.ts b/packages/react/src/useReactNodeView.ts index 27978f7b88c..185bd42a758 100644 --- a/packages/react/src/useReactNodeView.ts +++ b/packages/react/src/useReactNodeView.ts @@ -1,13 +1,15 @@ -import { createContext, ReactNode, useContext } from 'react' +import { + createContext, createElement, ReactNode, useContext, +} from 'react' export interface ReactNodeViewContextProps { - onDragStart: (event: DragEvent) => void, - nodeViewContentRef: (element: HTMLElement | null) => void, + onDragStart?: (event: DragEvent) => void; + nodeViewContentRef?: (element: HTMLElement | null) => void; /** * This allows you to add children into the NodeViewContent component. * This is useful when statically rendering the content of a node view. */ - nodeViewContentChildren: ReactNode, + nodeViewContentChildren?: ReactNode; } export const ReactNodeViewContext = createContext({ @@ -20,4 +22,18 @@ export const ReactNodeViewContext = createContext({ }, }) +export const ReactNodeViewContentProvider = ({ + children, + content, +}: { + children: ReactNode; + content: ReactNode; +}) => { + return createElement( + ReactNodeViewContext.Provider, + { value: { nodeViewContentChildren: content } }, + children, + ) +} + export const useReactNodeView = () => useContext(ReactNodeViewContext) diff --git a/packages/static-renderer/src/pm/react.example.tsx b/packages/static-renderer/src/pm/react.example.tsx index 50c5707983f..69bc845ebf6 100644 --- a/packages/static-renderer/src/pm/react.example.tsx +++ b/packages/static-renderer/src/pm/react.example.tsx @@ -1,11 +1,38 @@ /* eslint-disable no-plusplus */ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { + Node, + NodeViewContent, + ReactNodeViewContentProvider, + ReactNodeViewRenderer, +} from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' -import { renderToReactElement } from './react.js' +import { renderToReactElement } from './react.jsx' + +// This component does not have a NodeViewContent, so it does not render it's children's rich text content +function MyCustomComponentWithoutContent() { + const [count, setCount] = React.useState(200) + + return ( +
setCount(a => a + 1)}> + {count} This is a react component! +
+ ) +} + +// This component does have a NodeViewContent, so it will render it's children's rich text content +function MyCustomComponentWithContent() { + return ( +
+ Custom component with content in React! + +
+ ) +} /** * This example demonstrates how to render a Prosemirror Node (or JSON Content) to a React Element. @@ -15,12 +42,50 @@ import { renderToReactElement } from './react.js' * You have complete control over the rendering process. And can replace how each Node/Mark is rendered. */ +const CustomNodeExtensionWithContent = Node.create({ + name: 'customNodeExtensionWithContent', + content: 'text*', + group: 'block', + renderHTML() { + return ['div', { class: 'my-custom-component-with-content' }, 0] as const + }, + addNodeView() { + return ReactNodeViewRenderer(MyCustomComponentWithContent) + }, +}) + +const CustomNodeExtensionWithoutContent = Node.create({ + name: 'customNodeExtensionWithoutContent', + atom: true, + renderHTML() { + return ['div', { class: 'my-custom-component-without-content' }] as const + }, + addNodeView() { + return ReactNodeViewRenderer(MyCustomComponentWithoutContent) + }, +}) + const Element = renderToReactElement({ - extensions: [StarterKit], + extensions: [StarterKit, CustomNodeExtensionWithContent, CustomNodeExtensionWithoutContent], options: { nodeMapping: { + // You can replace the rendering of a node with a custom react component heading({ node, children }) { - return

THIS IS AN EXAMPLE OF CUSTOM RENDERING{children}

+ // eslint-disable-next-line react-hooks/rules-of-hooks + const [count, setCount] = React.useState(100) + + return

setCount(100)}>Can you use React hooks? {count}% {children}

+ }, + // Node views are not supported in the static renderer, so you need to supply the custom component yourself + customNodeExtensionWithContent({ children }) { + return ( + + + + ) + }, + customNodeExtensionWithoutContent() { + return }, }, markMapping: {}, @@ -46,6 +111,20 @@ const Element = renderToReactElement({ }, ], }, + // This is a custom node extension with content + { + type: 'customNodeExtensionWithContent', + content: [ + { + type: 'text', + text: 'MY CUSTOM COMPONENT CONTENT!!!', + }, + ], + }, + // This is a custom node extension without content + { + type: 'customNodeExtensionWithoutContent', + }, { type: 'paragraph', from: 11, From 2507e8b18fa24b6a69200e44ad618c2f79c5601b Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 27 Aug 2024 16:34:58 +0200 Subject: [PATCH 07/31] fix: better handling of unhandledNode & unhandledMark --- .../src/pm/extensionRenderer.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/static-renderer/src/pm/extensionRenderer.ts b/packages/static-renderer/src/pm/extensionRenderer.ts index e8a5a408e7c..b611df3b0a5 100644 --- a/packages/static-renderer/src/pm/extensionRenderer.ts +++ b/packages/static-renderer/src/pm/extensionRenderer.ts @@ -32,8 +32,8 @@ export function mapNodeExtensionToReactNode( domOutputSpecToElement: DomOutputSpecToElement, extension: NodeExtension, extensionAttributes: ExtensionAttribute[], + options?: Partial, 'unhandledNode'>>, ): [string, (props: NodeProps) => T] { - const context = { name: extension.name, options: extension.options, @@ -48,6 +48,9 @@ export function mapNodeExtensionToReactNode( ) if (!renderToHTML) { + if (options?.unhandledNode) { + return [extension.name, options.unhandledNode] + } return [ extension.name, () => { @@ -90,6 +93,7 @@ export function mapMarkExtensionToReactNode( domOutputSpecToElement: DomOutputSpecToElement, extension: MarkExtension, extensionAttributes: ExtensionAttribute[], + options?: Partial, 'unhandledMark'>>, ): [string, (props: MarkProps) => T] { const context = { name: extension.name, @@ -105,6 +109,9 @@ export function mapMarkExtensionToReactNode( ) if (!renderToHTML) { + if (options?.unhandledMark) { + return [extension.name, options.unhandledMark] + } return [ extension.name, () => { @@ -194,6 +201,7 @@ export function renderToElement({ domOutputSpecToElement, nodeExtension, extensionAttributes, + options, )), ), ...mapDefinedTypes, @@ -208,7 +216,13 @@ export function renderToElement({ return !(e.name in options.markMapping) } return true - }).map(mark => mapMarkExtensionToReactNode(domOutputSpecToElement, mark, extensionAttributes)), + }) + .map(mark => mapMarkExtensionToReactNode( + domOutputSpecToElement, + mark, + extensionAttributes, + options, + )), ), ...options?.markMapping, }, From fcad731dbd896988fac8a3a07c016277302a329e Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 25 Sep 2024 15:35:40 +0200 Subject: [PATCH 08/31] chore: add export --- packages/static-renderer/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 packages/static-renderer/src/index.ts diff --git a/packages/static-renderer/src/index.ts b/packages/static-renderer/src/index.ts new file mode 100644 index 00000000000..71b3d845de6 --- /dev/null +++ b/packages/static-renderer/src/index.ts @@ -0,0 +1,2 @@ +export * from './helpers.js' +export * from './types.js' From 580db9244db5bad4f3d5c7e34f01b3ce96549adc Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 21 Nov 2024 09:27:39 +0100 Subject: [PATCH 09/31] feat: add support for providing the current node and parent node to marks --- packages/static-renderer/src/json/renderer.ts | 52 +++++++++---------- .../src/pm/markdown.example.ts | 34 ++++++++++-- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/packages/static-renderer/src/json/renderer.ts b/packages/static-renderer/src/json/renderer.ts index b4ac95a61a1..f669c3c81df 100644 --- a/packages/static-renderer/src/json/renderer.ts +++ b/packages/static-renderer/src/json/renderer.ts @@ -29,15 +29,29 @@ export type NodeProps = { * The parent node of the current node */ parent?: TNodeType; - })=> TChildren; + }) => TChildren; }; /** * Props for a mark renderer */ -export type MarkProps = { +export type MarkProps = { + /** + * 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< @@ -60,18 +74,14 @@ export type TiptapStaticRendererOptions< /** * A node renderer is a function that takes a node and its children and returns the rendered output */ - TNodeRender extends ( - ctx: NodeProps - ) => TReturnType = ( + 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 + TMarkRender extends (ctx: MarkProps) => TReturnType = ( + ctx: MarkProps ) => TReturnType, > = { /** @@ -124,18 +134,14 @@ cb: (node: TNodeType) => void) => void }; /** * A node renderer is a function that takes a node and its children and returns the rendered output */ - TNodeRender extends ( - ctx: NodeProps - ) => TReturnType = ( + 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 + TMarkRender extends (ctx: MarkProps) => TReturnType = ( + ctx: MarkProps ) => TReturnType, >( /** @@ -149,7 +155,7 @@ cb: (node: TNodeType) => void) => void }; } | { component: TMarkRender; - props: MarkProps; + props: MarkProps; } ) => TReturnType, { @@ -157,13 +163,7 @@ cb: (node: TNodeType) => void) => void }; markMapping, unhandledNode, unhandledMark, - }: TiptapStaticRendererOptions< - TReturnType, - TMarkType, - TNodeType, - TNodeRender, - TMarkRender - >, + }: TiptapStaticRendererOptions, ) { /** * Render Tiptap JSON and all its children using the provided node and mark mappings. @@ -181,7 +181,6 @@ cb: (node: TNodeType) => void) => void }; */ parent?: TNodeType; }): TReturnType { - const nodeType = typeof content.type === 'string' ? content.type : content.type.name const NodeHandler = nodeMapping[nodeType] ?? unhandledNode @@ -197,7 +196,7 @@ cb: (node: TNodeType) => void) => void }; renderElement: renderContent, // Lazily compute the children to avoid unnecessary recursion get children() { - // recursively render child content nodes + // recursively render child content nodes const children: TReturnType[] = [] if (content.content) { @@ -231,6 +230,7 @@ cb: (node: TNodeType) => void) => void }; props: { mark, parent, + node: content, children: acc, }, }) diff --git a/packages/static-renderer/src/pm/markdown.example.ts b/packages/static-renderer/src/pm/markdown.example.ts index 5e97521b329..c97d5d26adf 100644 --- a/packages/static-renderer/src/pm/markdown.example.ts +++ b/packages/static-renderer/src/pm/markdown.example.ts @@ -46,12 +46,20 @@ const renderToMarkdown = ({ content }: { content: JSONContent | Node }) => rende return `${new Array(level).fill('#').join('')} ${children}\n` }, codeBlock({ node, children }) { - return `\n\`\`\`${node.attrs.language}\n${serializeChildrenToHTMLString(children)}\n\`\`\`\n` + return `\n\`\`\`${node.attrs.language}\n${serializeChildrenToHTMLString( + children, + )}\n\`\`\`\n` }, blockquote({ children }) { - return `\n${serializeChildrenToHTMLString(children).trim().split('\n').map(a => `> ${a}`) + return `\n${serializeChildrenToHTMLString(children) + .trim() + .split('\n') + .map(a => `> ${a}`) .join('\n')}` }, + image({ node }) { + return `![${node.attrs.alt}](${node.attrs.src})` + }, hardBreak() { return '\n' }, @@ -60,8 +68,23 @@ const renderToMarkdown = ({ content }: { content: JSONContent | Node }) => rende bold({ children }) { return `**${serializeChildrenToHTMLString(children)}**` }, - italic({ 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)}\`` }, }, }, @@ -127,6 +150,9 @@ console.log( { type: 'bold', }, + { + type: 'italic', + }, ], text: 'Tiptap', }, From 26da70c7b870a4559dbf44b1a60cc8f38a618092 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 4 Dec 2024 17:29:15 +0100 Subject: [PATCH 10/31] build: setup package to be built by tsup instead --- packages/static-renderer/package.json | 44 ++++++++++++++++--- packages/static-renderer/rollup.config.js | 5 --- .../src/json/html-string/index.ts | 2 + .../json/{ => html-string}/string.example.ts | 0 .../src/json/{ => html-string}/string.ts | 4 +- .../static-renderer/src/json/react/index.ts | 2 + .../src/json/{ => react}/react.example.ts | 4 +- .../src/json/{ => react}/react.tsx | 4 +- .../{ => html-string}/html-string.example.ts | 0 .../src/pm/{ => html-string}/html-string.ts | 8 ++-- .../src/pm/html-string/index.ts | 2 + .../src/pm/{ => markdown}/markdown.example.ts | 2 +- .../static-renderer/src/pm/react/index.ts | 2 + .../src/pm/{ => react}/react.example.tsx | 0 .../src/pm/{ => react}/react.tsx | 8 ++-- packages/static-renderer/tsup.config.ts | 19 ++++++++ 16 files changed, 81 insertions(+), 25 deletions(-) delete mode 100644 packages/static-renderer/rollup.config.js create mode 100644 packages/static-renderer/src/json/html-string/index.ts rename packages/static-renderer/src/json/{ => html-string}/string.example.ts (100%) rename packages/static-renderer/src/json/{ => html-string}/string.ts (91%) create mode 100644 packages/static-renderer/src/json/react/index.ts rename packages/static-renderer/src/json/{ => react}/react.example.ts (93%) rename packages/static-renderer/src/json/{ => react}/react.tsx (93%) rename packages/static-renderer/src/pm/{ => html-string}/html-string.example.ts (100%) rename packages/static-renderer/src/pm/{ => html-string}/html-string.ts (93%) create mode 100644 packages/static-renderer/src/pm/html-string/index.ts rename packages/static-renderer/src/pm/{ => markdown}/markdown.example.ts (99%) create mode 100644 packages/static-renderer/src/pm/react/index.ts rename packages/static-renderer/src/pm/{ => react}/react.example.tsx (100%) rename packages/static-renderer/src/pm/{ => react}/react.tsx (94%) create mode 100644 packages/static-renderer/tsup.config.ts diff --git a/packages/static-renderer/package.json b/packages/static-renderer/package.json index 70c723d867f..594cd7ba519 100644 --- a/packages/static-renderer/package.json +++ b/packages/static-renderer/package.json @@ -13,18 +13,52 @@ "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, + "type": "module", "exports": { ".": { - "types": "./dist/packages/static-renderer/src/index.d.ts", + "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" } }, "main": "dist/index.cjs", "module": "dist/index.js", - "umd": "dist/index.umd.js", - "types": "dist/packages/static-renderer/src/index.d.ts", - "type": "module", + "types": "dist/index.d.ts", "files": [ "src", "dist" @@ -40,6 +74,6 @@ }, "scripts": { "clean": "rm -rf dist", - "build": "npm run clean && rollup -c" + "build": "npm run clean && tsup" } } diff --git a/packages/static-renderer/rollup.config.js b/packages/static-renderer/rollup.config.js deleted file mode 100644 index cb8e994031b..00000000000 --- a/packages/static-renderer/rollup.config.js +++ /dev/null @@ -1,5 +0,0 @@ -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/json/html-string/index.ts b/packages/static-renderer/src/json/html-string/index.ts new file mode 100644 index 00000000000..671308561dc --- /dev/null +++ b/packages/static-renderer/src/json/html-string/index.ts @@ -0,0 +1,2 @@ +export * from '../renderer.js' +export * from './string.js' diff --git a/packages/static-renderer/src/json/string.example.ts b/packages/static-renderer/src/json/html-string/string.example.ts similarity index 100% rename from packages/static-renderer/src/json/string.example.ts rename to packages/static-renderer/src/json/html-string/string.example.ts diff --git a/packages/static-renderer/src/json/string.ts b/packages/static-renderer/src/json/html-string/string.ts similarity index 91% rename from packages/static-renderer/src/json/string.ts rename to packages/static-renderer/src/json/html-string/string.ts index e4aab8f4b3e..bb359af0899 100644 --- a/packages/static-renderer/src/json/string.ts +++ b/packages/static-renderer/src/json/html-string/string.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { MarkType, NodeType } from '../types.js' -import { TiptapStaticRenderer, TiptapStaticRendererOptions } from './renderer.js' +import { MarkType, NodeType } from '../../types.js' +import { TiptapStaticRenderer, TiptapStaticRendererOptions } from '../renderer.js' export function renderJSONContentToString< /** diff --git a/packages/static-renderer/src/json/react/index.ts b/packages/static-renderer/src/json/react/index.ts new file mode 100644 index 00000000000..93c1bed8481 --- /dev/null +++ b/packages/static-renderer/src/json/react/index.ts @@ -0,0 +1,2 @@ +export * from '../renderer.js' +export * from './react.js' diff --git a/packages/static-renderer/src/json/react.example.ts b/packages/static-renderer/src/json/react/react.example.ts similarity index 93% rename from packages/static-renderer/src/json/react.example.ts rename to packages/static-renderer/src/json/react/react.example.ts index b31606553eb..680943de610 100644 --- a/packages/static-renderer/src/json/react.example.ts +++ b/packages/static-renderer/src/json/react/react.example.ts @@ -1,8 +1,8 @@ import React from 'react' -import { NodeType } from '../types.js' +import { NodeType } from '../../types.js' +import { NodeProps } from '../renderer.js' import { renderJSONContentToReactElement } from './react.js' -import { NodeProps } from './renderer.js' /** * This example demonstrates how to render a JSON representation of a node to a React element diff --git a/packages/static-renderer/src/json/react.tsx b/packages/static-renderer/src/json/react/react.tsx similarity index 93% rename from packages/static-renderer/src/json/react.tsx rename to packages/static-renderer/src/json/react/react.tsx index 49aecb57dd9..97d10ac2c7c 100644 --- a/packages/static-renderer/src/json/react.tsx +++ b/packages/static-renderer/src/json/react/react.tsx @@ -2,8 +2,8 @@ import React from 'react' -import { MarkType, NodeType } from '../types.js' -import { TiptapStaticRenderer, TiptapStaticRendererOptions } from './renderer.js' +import { MarkType, NodeType } from '../../types.js' +import { TiptapStaticRenderer, TiptapStaticRendererOptions } from '../renderer.js' export function renderJSONContentToReactElement< /** diff --git a/packages/static-renderer/src/pm/html-string.example.ts b/packages/static-renderer/src/pm/html-string/html-string.example.ts similarity index 100% rename from packages/static-renderer/src/pm/html-string.example.ts rename to packages/static-renderer/src/pm/html-string/html-string.example.ts diff --git a/packages/static-renderer/src/pm/html-string.ts b/packages/static-renderer/src/pm/html-string/html-string.ts similarity index 93% rename from packages/static-renderer/src/pm/html-string.ts rename to packages/static-renderer/src/pm/html-string/html-string.ts index ca9fefb9c3f..f9fed9f6ff5 100644 --- a/packages/static-renderer/src/pm/html-string.ts +++ b/packages/static-renderer/src/pm/html-string/html-string.ts @@ -2,10 +2,10 @@ import { Extensions, JSONContent } from '@tiptap/core' import type { DOMOutputSpec, Mark, Node } from '@tiptap/pm/model' -import { TiptapStaticRendererOptions } from '../json/renderer.js' -import { renderJSONContentToString } from '../json/string.js' -import type { DOMOutputSpecArray } from '../types.js' -import { renderToElement } from './extensionRenderer.js' +import { renderJSONContentToString } from '../../json/html-string/string.js' +import { TiptapStaticRendererOptions } from '../../json/renderer.js' +import type { DOMOutputSpecArray } from '../../types.js' +import { renderToElement } from '../extensionRenderer.js' /** * Serialize the attributes of a node or mark to a string diff --git a/packages/static-renderer/src/pm/html-string/index.ts b/packages/static-renderer/src/pm/html-string/index.ts new file mode 100644 index 00000000000..878f8d4fd4c --- /dev/null +++ b/packages/static-renderer/src/pm/html-string/index.ts @@ -0,0 +1,2 @@ +export * from '../extensionRenderer.js' +export * from './html-string.js' diff --git a/packages/static-renderer/src/pm/markdown.example.ts b/packages/static-renderer/src/pm/markdown/markdown.example.ts similarity index 99% rename from packages/static-renderer/src/pm/markdown.example.ts rename to packages/static-renderer/src/pm/markdown/markdown.example.ts index c97d5d26adf..d818dde8d15 100644 --- a/packages/static-renderer/src/pm/markdown.example.ts +++ b/packages/static-renderer/src/pm/markdown/markdown.example.ts @@ -1,7 +1,7 @@ import { JSONContent } from '@tiptap/core' import StarterKit from '@tiptap/starter-kit' -import { renderToHTMLString, serializeChildrenToHTMLString } from './html-string.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. diff --git a/packages/static-renderer/src/pm/react/index.ts b/packages/static-renderer/src/pm/react/index.ts new file mode 100644 index 00000000000..16f27c0f336 --- /dev/null +++ b/packages/static-renderer/src/pm/react/index.ts @@ -0,0 +1,2 @@ +export * from '../extensionRenderer.js' +export * from './react.js' diff --git a/packages/static-renderer/src/pm/react.example.tsx b/packages/static-renderer/src/pm/react/react.example.tsx similarity index 100% rename from packages/static-renderer/src/pm/react.example.tsx rename to packages/static-renderer/src/pm/react/react.example.tsx diff --git a/packages/static-renderer/src/pm/react.tsx b/packages/static-renderer/src/pm/react/react.tsx similarity index 94% rename from packages/static-renderer/src/pm/react.tsx rename to packages/static-renderer/src/pm/react/react.tsx index 3f3783985d3..1ddafa20fae 100644 --- a/packages/static-renderer/src/pm/react.tsx +++ b/packages/static-renderer/src/pm/react/react.tsx @@ -3,10 +3,10 @@ import { Extensions, JSONContent } from '@tiptap/core' import type { DOMOutputSpec, Mark, Node } from '@tiptap/pm/model' import React from 'react' -import { renderJSONContentToReactElement } from '../json/react.js' -import { TiptapStaticRendererOptions } from '../json/renderer.js' -import type { DOMOutputSpecArray } from '../types.js' -import { renderToElement } from './extensionRenderer.js' +import { renderJSONContentToReactElement } from '../../json/react/react.js' +import { TiptapStaticRendererOptions } from '../../json/renderer.js' +import type { DOMOutputSpecArray } from '../../types.js' +import { renderToElement } from '../extensionRenderer.js' /** * This function maps the attributes of a node or mark to HTML attributes diff --git a/packages/static-renderer/tsup.config.ts b/packages/static-renderer/tsup.config.ts new file mode 100644 index 00000000000..b2b4b4fd918 --- /dev/null +++ b/packages/static-renderer/tsup.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'tsup' + +export default defineConfig( + [ + 'src/index.ts', + 'src/json/renderer.ts', + 'src/json/react/index.ts', + 'src/json/html-string/index.ts', + 'src/pm/react/index.ts', + 'src/pm/html-string/index.ts', + ].map(entry => ({ + entry: [entry], + tsconfig: '../../tsconfig.build.json', + outDir: `dist${entry.replace('src', '').split('/').slice(0, -1).join('/')}`, + dts: true, + sourcemap: true, + format: ['esm', 'cjs'], + })), +) From b35e8d25a4ec05edcc47e9aea7ca2fc7746aacdb Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 31 Dec 2024 11:17:01 +0100 Subject: [PATCH 11/31] build: fix build --- package-lock.json | 49 ++------------------------- packages/static-renderer/package.json | 6 ++-- packages/tsup.config.ts | 14 -------- 3 files changed, 6 insertions(+), 63 deletions(-) delete mode 100644 packages/tsup.config.ts diff --git a/package-lock.json b/package-lock.json index 42eeb16be6b..1866ad27e71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19841,54 +19841,11 @@ }, "packages/static-renderer": { "name": "@tiptap/static-renderer", - "version": "2.6.6", + "version": "3.0.0-next.1", "license": "MIT", "dependencies": { - "@tiptap/core": "^2.6.6", - "@tiptap/pm": "^2.6.6" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - } - }, - "packages/static-renderer/node_modules/@tiptap/core": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.0.tgz", - "integrity": "sha512-0S3AWx6E2QqwdQqb6z0/q6zq2u9lA9oL3BLyAaITGSC9zt8OwjloS2k1zN6wLa9hp2rO0c0vDnWsTPeFaEaMdw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/pm": "^2.7.0" - } - }, - "packages/static-renderer/node_modules/@tiptap/pm": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.0.tgz", - "integrity": "sha512-4RU6bpODkMY+ZshzdRFcuUc5jWlMW82LWXR6UOsHK/X/Mav41ZFS0Cyf+hQM6gxxTB09YFIICmGpEpULb+/CuA==", - "license": "MIT", - "dependencies": { - "prosemirror-changeset": "^2.2.1", - "prosemirror-collab": "^1.3.1", - "prosemirror-commands": "^1.6.2", - "prosemirror-dropcursor": "^1.8.1", - "prosemirror-gapcursor": "^1.3.2", - "prosemirror-history": "^1.4.1", - "prosemirror-inputrules": "^1.4.0", - "prosemirror-keymap": "^1.2.2", - "prosemirror-markdown": "^1.13.1", - "prosemirror-menu": "^1.2.4", - "prosemirror-model": "^1.23.0", - "prosemirror-schema-basic": "^1.2.3", - "prosemirror-schema-list": "^1.4.1", - "prosemirror-state": "^1.4.3", - "prosemirror-tables": "^1.6.1", - "prosemirror-trailing-node": "^3.0.0", - "prosemirror-transform": "^1.10.2", - "prosemirror-view": "^1.37.0" + "@tiptap/core": "^3.0.0-next.1", + "@tiptap/pm": "^3.0.0-next.1" }, "funding": { "type": "github", diff --git a/packages/static-renderer/package.json b/packages/static-renderer/package.json index 594cd7ba519..c25f3ff6eb9 100644 --- a/packages/static-renderer/package.json +++ b/packages/static-renderer/package.json @@ -1,7 +1,7 @@ { "name": "@tiptap/static-renderer", "description": "statically render Tiptap JSON", - "version": "2.6.6", + "version": "3.0.0-next.1", "homepage": "https://tiptap.dev", "keywords": [ "tiptap", @@ -64,8 +64,8 @@ "dist" ], "dependencies": { - "@tiptap/core": "^2.6.6", - "@tiptap/pm": "^2.6.6" + "@tiptap/core": "^3.0.0-next.1", + "@tiptap/pm": "^3.0.0-next.1" }, "repository": { "type": "git", diff --git a/packages/tsup.config.ts b/packages/tsup.config.ts deleted file mode 100644 index 9a367d5726f..00000000000 --- a/packages/tsup.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - entry: ['src/index.ts'], - tsconfig: '../../tsconfig.build.json', - outDir: 'dist', - dts: true, - clean: true, - sourcemap: true, - format: [ - 'esm', - 'cjs', - ], -}) From f83b06072248aead9252d4aa202ec9fdf37677f5 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 4 Dec 2024 17:48:50 +0100 Subject: [PATCH 12/31] chore: make package public --- packages/static-renderer/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/static-renderer/package.json b/packages/static-renderer/package.json index c25f3ff6eb9..955d80cf992 100644 --- a/packages/static-renderer/package.json +++ b/packages/static-renderer/package.json @@ -75,5 +75,8 @@ "scripts": { "clean": "rm -rf dist", "build": "npm run clean && tsup" + }, + "publishConfig": { + "access": "public" } } From 27199894f4c30eadf76321ca52600f8b267269f5 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 31 Dec 2024 11:17:14 +0100 Subject: [PATCH 13/31] feat(static-renderer): add markdown output support --- package-lock.json | 21 +- packages/static-renderer/package.json | 30 +- packages/static-renderer/src/index.ts | 4 + .../static-renderer/src/pm/markdown/index.ts | 2 + .../src/pm/markdown/markdown.example.ts | 1027 ++++++++++++++--- .../src/pm/markdown/markdown.ts | 144 +++ packages/static-renderer/tsup.config.ts | 2 + 7 files changed, 1051 insertions(+), 179 deletions(-) create mode 100644 packages/static-renderer/src/pm/markdown/index.ts create mode 100644 packages/static-renderer/src/pm/markdown/markdown.ts diff --git a/package-lock.json b/package-lock.json index 1866ad27e71..2716b6d2578 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19843,13 +19843,30 @@ "name": "@tiptap/static-renderer", "version": "3.0.0-next.1", "license": "MIT", - "dependencies": { + "devDependencies": { "@tiptap/core": "^3.0.0-next.1", - "@tiptap/pm": "^3.0.0-next.1" + "@tiptap/extension-text-align": "^3.0.0-next.1", + "@tiptap/extension-text-style": "^3.0.0-next.1", + "@tiptap/pm": "^3.0.0-next.1", + "@tiptap/starter-kit": "^3.0.0-next.1", + "@types/react": "^18.2.14", + "@types/react-dom": "^18.2.6", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@types/react": "^18.2.14", + "@types/react-dom": "^18.2.6", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependencies": { + "@tiptap/core": "^3.0.0-next.1", + "@tiptap/pm": "^3.0.0-next.1" } }, "packages/suggestion": { diff --git a/packages/static-renderer/package.json b/packages/static-renderer/package.json index 955d80cf992..1872bc4ac2b 100644 --- a/packages/static-renderer/package.json +++ b/packages/static-renderer/package.json @@ -54,6 +54,14 @@ }, "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" } }, "main": "dist/index.cjs", @@ -63,10 +71,27 @@ "src", "dist" ], - "dependencies": { + "devDependencies": { + "@tiptap/extension-text-style": "^3.0.0-next.1", + "@tiptap/extension-text-align": "^3.0.0-next.1", + "@tiptap/starter-kit": "^3.0.0-next.1", + "@tiptap/core": "^3.0.0-next.1", + "@tiptap/pm": "^3.0.0-next.1", + "@types/react": "^18.2.14", + "@types/react-dom": "^18.2.6", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependencies": { "@tiptap/core": "^3.0.0-next.1", "@tiptap/pm": "^3.0.0-next.1" }, + "optionalDependencies": { + "@types/react": "^18.2.14", + "@types/react-dom": "^18.2.6", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, "repository": { "type": "git", "url": "https://github.com/ueberdosis/tiptap", @@ -75,8 +100,5 @@ "scripts": { "clean": "rm -rf dist", "build": "npm run clean && tsup" - }, - "publishConfig": { - "access": "public" } } diff --git a/packages/static-renderer/src/index.ts b/packages/static-renderer/src/index.ts index 71b3d845de6..658e133872d 100644 --- a/packages/static-renderer/src/index.ts +++ b/packages/static-renderer/src/index.ts @@ -1,2 +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/react/index.js' export * from './types.js' diff --git a/packages/static-renderer/src/pm/markdown/index.ts b/packages/static-renderer/src/pm/markdown/index.ts new file mode 100644 index 00000000000..b8193d661e3 --- /dev/null +++ b/packages/static-renderer/src/pm/markdown/index.ts @@ -0,0 +1,2 @@ +export * from '../extensionRenderer.js' +export * from './markdown.js' diff --git a/packages/static-renderer/src/pm/markdown/markdown.example.ts b/packages/static-renderer/src/pm/markdown/markdown.example.ts index d818dde8d15..67a52712286 100644 --- a/packages/static-renderer/src/pm/markdown/markdown.example.ts +++ b/packages/static-renderer/src/pm/markdown/markdown.example.ts @@ -1,215 +1,414 @@ -import { JSONContent } from '@tiptap/core' +import { Highlight } from '@tiptap/extension-highlight' +import { Subscript } from '@tiptap/extension-subscript' +import { Superscript } from '@tiptap/extension-superscript' +import { Table } from '@tiptap/extension-table' +import { TableCell } from '@tiptap/extension-table-cell' +import { TableHeader } from '@tiptap/extension-table-header' +import { TableRow } from '@tiptap/extension-table-row' +import { TextAlign } from '@tiptap/extension-text-align' +import { TextStyle } from '@tiptap/extension-text-style' import StarterKit from '@tiptap/starter-kit' -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. - */ - -const renderToMarkdown = ({ content }: { content: JSONContent | Node }) => renderToHTMLString({ - content, - extensions: [StarterKit], - 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 `${new Array(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')}` - }, - image({ node }) { - return `![${node.attrs.alt}](${node.attrs.src})` - }, - hardBreak() { - return '\n' - }, - }, - 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)}\`` - }, - }, - }, -}) +import { renderToMarkdown } from './markdown.js' // eslint-disable-next-line no-console console.log( renderToMarkdown({ + extensions: [StarterKit, Subscript, Superscript, TextAlign, TextStyle, Highlight, Table, TableRow, TableCell, TableHeader], content: { type: 'doc', from: 0, - to: 574, + to: 747, content: [ { - type: 'heading', + type: 'paragraph', from: 0, - to: 11, - attrs: { - level: 2, - }, + to: 66, content: [ { type: 'text', from: 1, - to: 10, - text: 'Hi there,', + to: 65, + text: 'Markdown shortcuts make it easy to format the text while typing.', + }, + ], + }, + { + type: 'paragraph', + from: 66, + to: 205, + content: [ + { + type: 'text', + from: 67, + to: 107, + text: 'To test that, start a new line and type ', + }, + { + type: 'text', + from: 107, + to: 108, + marks: [ + { + type: 'code', + }, + ], + text: '#', + }, + { + type: 'text', + from: 108, + to: 151, + text: ' followed by a space to get a heading. Try ', + }, + { + type: 'text', + from: 151, + to: 152, + marks: [ + { + type: 'code', + }, + ], + text: '#', + }, + { + type: 'text', + from: 152, + to: 154, + text: ', ', + }, + { + type: 'text', + from: 154, + to: 156, + marks: [ + { + type: 'code', + }, + ], + text: '##', + }, + { + type: 'text', + from: 156, + to: 158, + text: ', ', + }, + { + type: 'text', + from: 158, + to: 161, + marks: [ + { + type: 'code', + }, + ], + text: '###', + }, + { + type: 'text', + from: 161, + to: 163, + text: ', ', + }, + { + type: 'text', + from: 163, + to: 167, + marks: [ + { + type: 'code', + }, + ], + text: '####', + }, + { + type: 'text', + from: 167, + to: 169, + text: ', ', + }, + { + type: 'text', + from: 169, + to: 174, + marks: [ + { + type: 'code', + }, + ], + text: '#####', + }, + { + type: 'text', + from: 174, + to: 176, + text: ', ', + }, + { + type: 'text', + from: 176, + to: 182, + marks: [ + { + type: 'code', + }, + ], + text: '######', + }, + { + type: 'text', + from: 182, + to: 204, + text: ' for different levels.', }, ], }, { type: 'paragraph', - from: 11, - to: 169, + from: 205, + to: 442, content: [ { type: 'text', - from: 12, - to: 22, - text: 'this is a ', + from: 206, + to: 299, + text: 'Those conventions are called input rules in Tiptap. Some of them are enabled by default. Try ', + }, + { + type: 'text', + from: 299, + to: 300, + marks: [ + { + type: 'code', + }, + ], + text: '>', + }, + { + type: 'text', + from: 300, + to: 318, + text: ' for blockquotes, ', + }, + { + type: 'text', + from: 318, + to: 319, + marks: [ + { + type: 'code', + }, + ], + text: '*', + }, + { + type: 'text', + from: 319, + to: 321, + text: ', ', + }, + { + type: 'text', + from: 321, + to: 322, + marks: [ + { + type: 'code', + }, + ], + text: '-', + }, + { + type: 'text', + from: 322, + to: 326, + text: ' or ', + }, + { + type: 'text', + from: 326, + to: 327, + marks: [ + { + type: 'code', + }, + ], + text: '+', + }, + { + type: 'text', + from: 327, + to: 349, + text: ' for bullet lists, or ', + }, + { + type: 'text', + from: 349, + to: 357, + marks: [ + { + type: 'code', + }, + ], + text: '`foobar`', + }, + { + type: 'text', + from: 357, + to: 377, + text: ' to highlight code, ', }, { type: 'text', - from: 22, - to: 27, + from: 377, + to: 387, marks: [ { - type: 'italic', + type: 'code', }, ], - text: 'basic', + text: '~~tildes~~', }, { type: 'text', - from: 27, - to: 39, - text: ' example of ', + from: 387, + to: 407, + text: ' to strike text, or ', }, { type: 'text', - from: 39, - to: 45, + from: 407, + to: 422, marks: [ { - type: 'bold', + type: 'code', }, + ], + text: '==equal signs==', + }, + { + type: 'text', + from: 422, + to: 441, + text: ' to highlight text.', + }, + ], + }, + { + type: 'paragraph', + from: 442, + to: 450, + content: [ + { + type: 'text', + from: 443, + to: 447, + marks: [ { - type: 'italic', + type: 'highlight', }, ], - text: 'Tiptap', + text: 'TEST', }, { 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:', + from: 447, + to: 449, + text: ' f', + }, + ], + }, + { + type: 'blockquote', + from: 450, + to: 459, + content: [ + { + type: 'paragraph', + from: 451, + to: 458, + content: [ + { + type: 'text', + from: 452, + to: 457, + text: 'fodks', + }, + ], + }, + ], + }, + { + type: 'paragraph', + from: 459, + to: 463, + content: [ + { + type: 'text', + from: 460, + to: 462, + marks: [ + { + type: 'code', + }, + ], + text: 'ok', }, ], }, { type: 'bulletList', - from: 169, - to: 230, + from: 463, + to: 475, content: [ { type: 'listItem', - from: 170, - to: 205, - attrs: { - color: '', - }, + from: 464, + to: 474, content: [ { type: 'paragraph', - from: 171, - to: 204, + from: 465, + to: 473, content: [ { type: 'text', - from: 172, - to: 203, - text: 'That’s a bullet list with one …', + from: 466, + to: 472, + text: 'bullet', }, ], }, ], }, + ], + }, + { + type: 'orderedList', + from: 475, + to: 486, + attrs: { + start: 1, + }, + content: [ { type: 'listItem', - from: 205, - to: 229, - attrs: { - color: '', - }, + from: 476, + to: 485, content: [ { type: 'paragraph', - from: 206, - to: 228, + from: 477, + to: 484, content: [ { type: 'text', - from: 207, - to: 227, - text: '… or two list items.', + from: 478, + to: 483, + text: 'order', }, ], }, @@ -219,72 +418,554 @@ console.log( }, { type: 'paragraph', - from: 230, - to: 326, + from: 486, + to: 492, 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:', + from: 487, + to: 491, + marks: [ + { + type: 'strike', + }, + ], + text: 'test', }, ], }, { - type: 'codeBlock', - from: 326, - to: 353, + type: 'heading', + from: 492, + to: 496, attrs: { - language: 'css', + level: 1, }, content: [ { type: 'text', - from: 327, - to: 352, - text: 'body {\n display: none;\n}', + from: 493, + to: 495, + text: 'h1', }, ], }, { type: 'paragraph', - from: 353, - to: 522, + from: 496, + to: 584, 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.', + from: 497, + to: 583, + text: 'You can overwrite existing input rules or add your own to nodes, marks and extensions.', }, ], }, { - type: 'blockquote', - from: 522, - to: 572, + type: 'paragraph', + from: 584, + to: 745, content: [ { - type: 'paragraph', - from: 523, - to: 571, + type: 'text', + from: 585, + to: 611, + text: 'For example, we added the ', + }, + { + type: 'text', + from: 611, + to: 621, + marks: [ + { + type: 'code', + }, + ], + text: 'Typography', + }, + { + type: 'text', + from: 621, + to: 649, + text: ' extension here. Try typing ', + }, + { + type: 'text', + from: 649, + to: 652, + marks: [ + { + type: 'code', + }, + ], + text: '(c)', + }, + { + type: 'text', + from: 652, + to: 721, + text: ' to see how it’s converted to a proper © character. You can also try ', + }, + { + type: 'text', + from: 721, + to: 723, + marks: [ + { + type: 'code', + }, + ], + text: '->', + }, + { + type: 'text', + from: 723, + to: 725, + text: ', ', + }, + { + type: 'text', + from: 725, + to: 727, + marks: [ + { + type: 'code', + }, + ], + text: '>>', + }, + { + type: 'text', + from: 727, + to: 729, + text: ', ', + }, + { + type: 'text', + from: 729, + to: 732, + marks: [ + { + type: 'code', + }, + ], + text: '1/2', + }, + { + type: 'text', + from: 732, + to: 734, + text: ', ', + }, + { + type: 'text', + from: 734, + to: 736, + marks: [ + { + type: 'code', + }, + ], + text: '!=', + }, + { + type: 'text', + from: 736, + to: 741, + text: ', or ', + }, + { + type: 'text', + from: 741, + to: 743, + marks: [ + { + type: 'code', + }, + ], + text: '--', + }, + { + type: 'text', + from: 743, + to: 744, + text: '.', + }, + ], + }, + { + type: 'table', + from: 195, + to: 380, + content: [ + { + type: 'tableRow', + from: 196, + to: 221, content: [ { - type: 'text', - from: 524, - to: 564, - text: 'Wow, that’s amazing. Good work, boy! 👏 ', + type: 'tableHeader', + from: 197, + to: 205, + attrs: { + colspan: 1, + rowspan: 1, + colwidth: [ + 200, + ], + }, + content: [ + { + type: 'paragraph', + from: 198, + to: 204, + content: [ + { + type: 'text', + from: 199, + to: 203, + text: 'Name', + }, + ], + }, + ], }, { - type: 'hardBreak', - from: 564, - to: 565, + type: 'tableHeader', + from: 205, + to: 220, + attrs: { + colspan: 3, + rowspan: 1, + colwidth: [ + 150, + 100, + ], + }, + content: [ + { + type: 'paragraph', + from: 206, + to: 219, + content: [ + { + type: 'text', + from: 207, + to: 218, + text: 'Description', + }, + ], + }, + ], }, + ], + }, + { + type: 'tableRow', + from: 221, + to: 274, + content: [ { - type: 'text', - from: 565, - to: 570, - text: '— Mom', + type: 'tableCell', + from: 222, + to: 238, + attrs: { + colspan: 1, + rowspan: 1, + colwidth: null, + backgroundColor: null, + }, + content: [ + { + type: 'paragraph', + from: 223, + to: 237, + content: [ + { + type: 'text', + from: 224, + to: 236, + text: 'Cyndi Lauper', + }, + ], + }, + ], + }, + { + type: 'tableCell', + from: 238, + to: 248, + attrs: { + colspan: 1, + rowspan: 1, + colwidth: null, + backgroundColor: null, + }, + content: [ + { + type: 'paragraph', + from: 239, + to: 247, + content: [ + { + type: 'text', + from: 240, + to: 246, + text: 'Singer', + }, + ], + }, + ], + }, + { + type: 'tableCell', + from: 248, + to: 262, + attrs: { + colspan: 1, + rowspan: 1, + colwidth: null, + backgroundColor: null, + }, + content: [ + { + type: 'paragraph', + from: 249, + to: 261, + content: [ + { + type: 'text', + from: 250, + to: 260, + text: 'Songwriter', + }, + ], + }, + ], + }, + { + type: 'tableCell', + from: 262, + to: 273, + attrs: { + colspan: 1, + rowspan: 1, + colwidth: null, + backgroundColor: null, + }, + content: [ + { + type: 'paragraph', + from: 263, + to: 272, + content: [ + { + type: 'text', + from: 264, + to: 271, + text: 'Actress', + }, + ], + }, + ], + }, + ], + }, + { + type: 'tableRow', + from: 274, + to: 328, + content: [ + { + type: 'tableCell', + from: 275, + to: 290, + attrs: { + colspan: 1, + rowspan: 1, + colwidth: null, + backgroundColor: null, + }, + content: [ + { + type: 'paragraph', + from: 276, + to: 289, + content: [ + { + type: 'text', + from: 277, + to: 288, + text: 'Marie Curie', + }, + ], + }, + ], + }, + { + type: 'tableCell', + from: 290, + to: 303, + attrs: { + colspan: 1, + rowspan: 1, + colwidth: null, + backgroundColor: null, + }, + content: [ + { + type: 'paragraph', + from: 291, + to: 302, + content: [ + { + type: 'text', + from: 292, + to: 301, + text: 'Scientist', + }, + ], + }, + ], + }, + { + type: 'tableCell', + from: 303, + to: 314, + attrs: { + colspan: 1, + rowspan: 1, + colwidth: null, + backgroundColor: null, + }, + content: [ + { + type: 'paragraph', + from: 304, + to: 313, + content: [ + { + type: 'text', + from: 305, + to: 312, + text: 'Chemist', + }, + ], + }, + ], + }, + { + type: 'tableCell', + from: 314, + to: 327, + attrs: { + colspan: 1, + rowspan: 1, + colwidth: null, + backgroundColor: null, + }, + content: [ + { + type: 'paragraph', + from: 315, + to: 326, + content: [ + { + type: 'text', + from: 316, + to: 325, + text: 'Physicist', + }, + ], + }, + ], + }, + ], + }, + { + type: 'tableRow', + from: 328, + to: 379, + content: [ + { + type: 'tableCell', + from: 329, + to: 346, + attrs: { + colspan: 1, + rowspan: 1, + colwidth: null, + backgroundColor: null, + }, + content: [ + { + type: 'paragraph', + from: 330, + to: 345, + content: [ + { + type: 'text', + from: 331, + to: 344, + text: 'Indira Gandhi', + }, + ], + }, + ], + }, + { + type: 'tableCell', + from: 346, + to: 364, + attrs: { + colspan: 1, + rowspan: 1, + colwidth: null, + backgroundColor: null, + }, + content: [ + { + type: 'paragraph', + from: 347, + to: 363, + content: [ + { + type: 'text', + from: 348, + to: 362, + text: 'Prime minister', + }, + ], + }, + ], + }, + { + type: 'tableCell', + from: 364, + to: 378, + attrs: { + colspan: 2, + rowspan: 1, + colwidth: null, + backgroundColor: null, + }, + content: [ + { + type: 'paragraph', + from: 365, + to: 377, + content: [ + { + type: 'text', + from: 366, + to: 376, + text: 'Politician', + }, + ], + }, + ], }, ], }, diff --git a/packages/static-renderer/src/pm/markdown/markdown.ts b/packages/static-renderer/src/pm/markdown/markdown.ts new file mode 100644 index 00000000000..e107378bd5b --- /dev/null +++ b/packages/static-renderer/src/pm/markdown/markdown.ts @@ -0,0 +1,144 @@ +import { Extensions, JSONContent } from '@tiptap/core' +import type { Mark, Node } from '@tiptap/pm/model' + +import { TiptapStaticRendererOptions } from '../../json/renderer.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. + */ +export function renderToMarkdown({ + content, + extensions, + options, +}: { + content: Node | JSONContent; + extensions: Extensions; + options?: Partial>; +}) { + return renderToHTMLString({ + content, + extensions, + 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 `${new Array(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')}` + }, + 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` + } + + return `\n${serializeChildrenToHTMLString(children[0])}| ${new Array(node.childCount - 2).fill('---').join(' | ')} |\n${serializeChildrenToHTMLString(children.slice(1))}\n` + }, + tableRow({ children }) { + if (Array.isArray(children)) { + return `| ${children.join(' | ')} |\n` + } + return `${serializeChildrenToHTMLString(children)}\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 `${serializeChildrenToHTMLString(children)}` + }, + subscript({ children }) { + return `${serializeChildrenToHTMLString(children)}` + }, + superscript({ children }) { + return `${serializeChildrenToHTMLString(children)}` + }, + link({ node, children }) { + return `[${serializeChildrenToHTMLString(children)}](${node.attrs.href})` + }, + highlight({ children }) { + return `==${serializeChildrenToHTMLString(children)}==` + }, + ...options?.markMapping, + }, + ...options, + }, + }) +} diff --git a/packages/static-renderer/tsup.config.ts b/packages/static-renderer/tsup.config.ts index b2b4b4fd918..461a1a88f3e 100644 --- a/packages/static-renderer/tsup.config.ts +++ b/packages/static-renderer/tsup.config.ts @@ -8,6 +8,7 @@ export default defineConfig( 'src/json/html-string/index.ts', 'src/pm/react/index.ts', 'src/pm/html-string/index.ts', + 'src/pm/markdown/index.ts', ].map(entry => ({ entry: [entry], tsconfig: '../../tsconfig.build.json', @@ -15,5 +16,6 @@ export default defineConfig( dts: true, sourcemap: true, format: ['esm', 'cjs'], + external: [/^[^./]/], })), ) From 10b9ffbfcbb831c99b4ac4b9ec544941d5ba83df Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 18 Dec 2024 09:25:51 +0100 Subject: [PATCH 14/31] fix: access nodeOrMark's type safely across JSON & Prosemirror objects --- packages/static-renderer/src/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/static-renderer/src/helpers.ts b/packages/static-renderer/src/helpers.ts index b1fa2e20e73..2a04a18bd95 100644 --- a/packages/static-renderer/src/helpers.ts +++ b/packages/static-renderer/src/helpers.ts @@ -22,7 +22,7 @@ export function getAttributes( return extensionAttributes .filter(item => { - if (item.type !== nodeOrMark.type) { + if (item.type !== (typeof nodeOrMark.type === 'string' ? nodeOrMark.type : nodeOrMark.type.name)) { return false } if (onlyRenderedAttributes) { From d66bab4eae2bf6a778f8209f845e4370b420e57a Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 18 Dec 2024 10:05:49 +0100 Subject: [PATCH 15/31] chore: widen the type to represent the actual representation --- packages/static-renderer/src/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/static-renderer/src/types.ts b/packages/static-renderer/src/types.ts index 1b1352f1124..da821dacfce 100644 --- a/packages/static-renderer/src/types.ts +++ b/packages/static-renderer/src/types.ts @@ -4,7 +4,7 @@ * A mark type is either a JSON representation of a mark or a Prosemirror mark instance */ export type MarkType< - Type extends string = any, + Type extends string | { name: string } = any, Attributes extends undefined | Record = any, > = { type: Type; @@ -15,7 +15,7 @@ export type MarkType< * A node type is either a JSON representation of a node or a Prosemirror node instance */ export type NodeType< - Type extends string = any, + Type extends string | { name: string } = any, Attributes extends undefined | Record = any, NodeMarkType extends MarkType = any, Content extends NodeType[] = any, From 24c10098f353656b2a91e4f1e4fc628a6add2f02 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 30 Dec 2024 08:56:51 +0100 Subject: [PATCH 16/31] feat(static-renderer): add support for namespacing in DOMOutputSpec --- .../src/pm/html-string/html-string.ts | 10 +++++++-- .../static-renderer/src/pm/react/react.tsx | 22 ++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/static-renderer/src/pm/html-string/html-string.ts b/packages/static-renderer/src/pm/html-string/html-string.ts index f9fed9f6ff5..d9b03dfc675 100644 --- a/packages/static-renderer/src/pm/html-string/html-string.ts +++ b/packages/static-renderer/src/pm/html-string/html-string.ts @@ -14,7 +14,7 @@ import { renderToElement } from '../extensionRenderer.js' */ export function serializeAttrsToHTMLString(attrs: Record): string { const output = Object.entries(attrs) - .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .map(([key, value]) => `${key.split(' ').at(-1)}=${JSON.stringify(value)}`) .join(' ') return output ? ` ${output}` : '' @@ -44,7 +44,13 @@ export function domOutputSpecToHTMLString( return () => content } if (typeof content === 'object' && 'length' in content) { - const [tag, attrs, children, ...rest] = content as DOMOutputSpecArray + 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}/>` diff --git a/packages/static-renderer/src/pm/react/react.tsx b/packages/static-renderer/src/pm/react/react.tsx index 1ddafa20fae..2cbc385bdc0 100644 --- a/packages/static-renderer/src/pm/react/react.tsx +++ b/packages/static-renderer/src/pm/react/react.tsx @@ -42,7 +42,27 @@ export function domOutputSpecToReactElement( return () => content } if (typeof content === 'object' && 'length' in content) { - const [tag, attrs, children, ...rest] = content as DOMOutputSpecArray + // eslint-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())) From ec95ac0836bea3085edfe33a52eac974c8307588 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 31 Dec 2024 14:16:01 +0100 Subject: [PATCH 17/31] feat: move types into core package --- packages/core/src/Editor.ts | 9 ++- packages/core/src/types.ts | 55 ++++++++++++++++++ packages/static-renderer/src/helpers.ts | 7 ++- packages/static-renderer/src/index.ts | 1 - .../src/json/html-string/string.example.ts | 5 +- .../src/json/html-string/string.ts | 3 +- .../src/json/react/react.example.ts | 4 +- .../static-renderer/src/json/react/react.tsx | 2 +- packages/static-renderer/src/json/renderer.ts | 10 ++-- .../src/pm/html-string/html-string.ts | 3 +- .../static-renderer/src/pm/react/react.tsx | 3 +- packages/static-renderer/src/types.ts | 57 ------------------- .../integration/extensions/tableCell.spec.ts | 2 + .../extensions/tableHeader.spec.ts | 2 + 14 files changed, 85 insertions(+), 78 deletions(-) delete mode 100644 packages/static-renderer/src/types.ts diff --git a/packages/core/src/Editor.ts b/packages/core/src/Editor.ts index cd8b5688737..5e54bc8aec1 100644 --- a/packages/core/src/Editor.ts +++ b/packages/core/src/Editor.ts @@ -30,11 +30,13 @@ import { style } from './style.js' import { CanCommands, ChainedCommands, + DocumentType, EditorEvents, EditorOptions, - JSONContent, + NodeType as TNodeType, SingleCommands, TextSerializer, + TextType as TTextType, } from './types.js' import { createStyleTag } from './utilities/createStyleTag.js' import { isFunction } from './utilities/isFunction.js' @@ -533,7 +535,10 @@ export class Editor extends EventEmitter { /** * Get the document as JSON. */ - public getJSON(): JSONContent { + public getJSON(): DocumentType< + Record | undefined, + TNodeType, any, (TNodeType | TTextType)[]>[] + > { return this.state.doc.toJSON() } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 74b8d84f55f..03367a25d5e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -159,6 +159,61 @@ export type JSONContent = { [key: string]: any; }; +/** + * A mark type is either a JSON representation of a mark or a Prosemirror mark instance + */ +export type MarkType< + Type extends string | { name: string } = any, + Attributes extends undefined | Record = any, +> = { + type: Type; + attrs: Attributes; +}; + +/** + * A node type is either a JSON representation of a node or a Prosemirror node instance + */ +export type NodeType< + Type extends string | { name: string } = any, + Attributes extends undefined | Record = any, + NodeMarkType extends MarkType = any, + Content extends (NodeType | TextType)[] = any, +> = { + type: Type; + attrs: Attributes; + content?: Content; + marks?: NodeMarkType[]; +}; + +/** + * A node type is either a JSON representation of a doc node or a Prosemirror doc node instance + */ +export type DocumentType< + TDocAttributes extends Record | undefined = Record, + TContentType extends NodeType[] = NodeType[], +> = Omit, 'marks' | 'content'> & {content: TContentType}; + +/** + * A node type is either a JSON representation of a text node or a Prosemirror text node instance + */ +export type TextType = { + type: 'text'; + text: string; + marks: TMarkType[]; +}; + +/** + * Describes the output of a `renderHTML` function in prosemirror + * @see https://prosemirror.net/docs/ref/#model.DOMOutputSpec + */ +export type DOMOutputSpecArray = + | [string] + | [string, Record] + | [string, 0] + | [string, Record, 0] + | [string, Record, DOMOutputSpecArray | 0] + | [string, DOMOutputSpecArray]; + export type Content = HTMLContent | JSONContent | JSONContent[] | null; export type CommandProps = { diff --git a/packages/static-renderer/src/helpers.ts b/packages/static-renderer/src/helpers.ts index 2a04a18bd95..a05bcd9c01e 100644 --- a/packages/static-renderer/src/helpers.ts +++ b/packages/static-renderer/src/helpers.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { ExtensionAttribute, mergeAttributes } from '@tiptap/core' - -import type { MarkType, NodeType } from './types' +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. diff --git a/packages/static-renderer/src/index.ts b/packages/static-renderer/src/index.ts index 658e133872d..5e1d2cfb937 100644 --- a/packages/static-renderer/src/index.ts +++ b/packages/static-renderer/src/index.ts @@ -3,4 +3,3 @@ export * from './json/html-string/index.js' export * from './json/react/index.js' export * from './pm/html-string/index.js' export * from './pm/react/index.js' -export * from './types.js' diff --git a/packages/static-renderer/src/json/html-string/string.example.ts b/packages/static-renderer/src/json/html-string/string.example.ts index 2240b01aa92..60ebb90cea4 100644 --- a/packages/static-renderer/src/json/html-string/string.example.ts +++ b/packages/static-renderer/src/json/html-string/string.example.ts @@ -1,3 +1,5 @@ +import type { TextType } from '@tiptap/core' + import { renderJSONContentToString } from './string.js' /** @@ -14,8 +16,7 @@ console.log( renderJSONContentToString({ nodeMapping: { text({ node }) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return node.text! + return (node as unknown as TextType).text }, heading({ node, children }) { const level = node.attrs?.level diff --git a/packages/static-renderer/src/json/html-string/string.ts b/packages/static-renderer/src/json/html-string/string.ts index bb359af0899..77b645de4a7 100644 --- a/packages/static-renderer/src/json/html-string/string.ts +++ b/packages/static-renderer/src/json/html-string/string.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { MarkType, NodeType } from '../../types.js' +import type { MarkType, NodeType } from '@tiptap/core' + import { TiptapStaticRenderer, TiptapStaticRendererOptions } from '../renderer.js' export function renderJSONContentToString< diff --git a/packages/static-renderer/src/json/react/react.example.ts b/packages/static-renderer/src/json/react/react.example.ts index 680943de610..6128d6a4c37 100644 --- a/packages/static-renderer/src/json/react/react.example.ts +++ b/packages/static-renderer/src/json/react/react.example.ts @@ -1,6 +1,6 @@ +import type { NodeType, TextType } from '@tiptap/core' import React from 'react' -import { NodeType } from '../../types.js' import { NodeProps } from '../renderer.js' import { renderJSONContentToReactElement } from './react.js' @@ -17,7 +17,7 @@ import { renderJSONContentToReactElement } from './react.js' console.log(renderJSONContentToReactElement({ nodeMapping: { text({ node }) { - return node.text ?? null + return (node as unknown as TextType).text ?? null }, heading({ node, diff --git a/packages/static-renderer/src/json/react/react.tsx b/packages/static-renderer/src/json/react/react.tsx index 97d10ac2c7c..8a77e6aa930 100644 --- a/packages/static-renderer/src/json/react/react.tsx +++ b/packages/static-renderer/src/json/react/react.tsx @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { MarkType, NodeType } from '@tiptap/core' import React from 'react' -import { MarkType, NodeType } from '../../types.js' import { TiptapStaticRenderer, TiptapStaticRendererOptions } from '../renderer.js' export function renderJSONContentToReactElement< diff --git a/packages/static-renderer/src/json/renderer.ts b/packages/static-renderer/src/json/renderer.ts index f669c3c81df..26a0d58be54 100644 --- a/packages/static-renderer/src/json/renderer.ts +++ b/packages/static-renderer/src/json/renderer.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { MarkType, NodeType } from '../types' +import type { MarkType, NodeType } from '@tiptap/core' /** * Props for a node renderer @@ -87,19 +87,19 @@ export type TiptapStaticRendererOptions< /** * Mapping of node types to react components */ - nodeMapping: Record; + nodeMapping: Record>; /** * Mapping of mark types to react components */ - markMapping: Record; + markMapping: Record>; /** * Component to render if a node type is not handled */ - unhandledNode?: TNodeRender; + unhandledNode?: NoInfer; /** * Component to render if a mark type is not handled */ - unhandledMark?: TMarkRender; + unhandledMark?: NoInfer; }; /** diff --git a/packages/static-renderer/src/pm/html-string/html-string.ts b/packages/static-renderer/src/pm/html-string/html-string.ts index d9b03dfc675..69e24fc5c2e 100644 --- a/packages/static-renderer/src/pm/html-string/html-string.ts +++ b/packages/static-renderer/src/pm/html-string/html-string.ts @@ -1,10 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Extensions, JSONContent } from '@tiptap/core' +import type { DOMOutputSpecArray, Extensions, JSONContent } from '@tiptap/core' import type { DOMOutputSpec, Mark, Node } from '@tiptap/pm/model' import { renderJSONContentToString } from '../../json/html-string/string.js' import { TiptapStaticRendererOptions } from '../../json/renderer.js' -import type { DOMOutputSpecArray } from '../../types.js' import { renderToElement } from '../extensionRenderer.js' /** diff --git a/packages/static-renderer/src/pm/react/react.tsx b/packages/static-renderer/src/pm/react/react.tsx index 2cbc385bdc0..e966c9a3c91 100644 --- a/packages/static-renderer/src/pm/react/react.tsx +++ b/packages/static-renderer/src/pm/react/react.tsx @@ -1,11 +1,10 @@ /* eslint-disable no-plusplus, @typescript-eslint/no-explicit-any */ -import { Extensions, JSONContent } from '@tiptap/core' +import type { DOMOutputSpecArray, Extensions, JSONContent } from '@tiptap/core' import type { DOMOutputSpec, Mark, Node } from '@tiptap/pm/model' import React from 'react' import { renderJSONContentToReactElement } from '../../json/react/react.js' import { TiptapStaticRendererOptions } from '../../json/renderer.js' -import type { DOMOutputSpecArray } from '../../types.js' import { renderToElement } from '../extensionRenderer.js' /** diff --git a/packages/static-renderer/src/types.ts b/packages/static-renderer/src/types.ts deleted file mode 100644 index da821dacfce..00000000000 --- a/packages/static-renderer/src/types.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -/** - * A mark type is either a JSON representation of a mark or a Prosemirror mark instance - */ -export type MarkType< - Type extends string | { name: string } = any, - Attributes extends undefined | Record = any, -> = { - type: Type; - attrs: Attributes; -}; - -/** - * A node type is either a JSON representation of a node or a Prosemirror node instance - */ -export type NodeType< - Type extends string | { name: 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; -}; - -/** - * A node type is either a JSON representation of a doc node or a Prosemirror doc node instance - */ -export type DocumentType< - TNodeAttributes extends Record = Record, - TContentType extends NodeType[] = NodeType[], -> = NodeType<'doc', TNodeAttributes, never, TContentType>; - -/** - * A node type is either a JSON representation of a text node or a Prosemirror text node instance - */ -export type TextType = { - type: 'text'; - text: string; - marks: TMarkType[]; -}; - -/** - * Describes the output of a `renderHTML` function in prosemirror - * @see https://prosemirror.net/docs/ref/#model.DOMOutputSpec - */ -export type DOMOutputSpecArray = - | [string] - | [string, Record] - | [string, 0] - | [string, Record, 0] - | [string, Record, DOMOutputSpecArray | 0] - | [string, DOMOutputSpecArray]; diff --git a/tests/cypress/integration/extensions/tableCell.spec.ts b/tests/cypress/integration/extensions/tableCell.spec.ts index fb3c3e98bca..c068a57d6ca 100644 --- a/tests/cypress/integration/extensions/tableCell.spec.ts +++ b/tests/cypress/integration/extensions/tableCell.spec.ts @@ -66,6 +66,7 @@ describe('extension table cell', () => { content, }) + // @ts-expect-error content is not guaranteed to be this shape expect(editor.getJSON().content[0].content[0].content[0].attrs.colwidth[0]).to.eq(200) editor?.destroy() @@ -91,6 +92,7 @@ describe('extension table cell', () => { content, }) + // @ts-expect-error content is not guaranteed to be this shape expect(editor.getJSON().content[0].content[0].content[1].attrs.colwidth).deep.equal([150, 100]) editor?.destroy() diff --git a/tests/cypress/integration/extensions/tableHeader.spec.ts b/tests/cypress/integration/extensions/tableHeader.spec.ts index 04464492b8e..dd2fd14b92f 100644 --- a/tests/cypress/integration/extensions/tableHeader.spec.ts +++ b/tests/cypress/integration/extensions/tableHeader.spec.ts @@ -66,6 +66,7 @@ describe('extension table header', () => { content, }) + // @ts-expect-error content is not guaranteed to be this shape expect(editor.getJSON().content[0].content[0].content[0].attrs.colwidth[0]).to.eq(200) editor?.destroy() @@ -91,6 +92,7 @@ describe('extension table header', () => { content, }) + // @ts-expect-error content is not guaranteed to be this shape expect(editor.getJSON().content[0].content[0].content[1].attrs.colwidth).deep.equal([150, 100]) editor?.destroy() From df3d708d07782d4620c53fbebc026383b8d033e2 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 31 Dec 2024 15:19:37 +0100 Subject: [PATCH 18/31] test: add tests for static renderer --- .../src/json/html-string/string.ts | 25 ++ .../src/pm/html-string/html-string.ts | 32 +-- .../static-renderer/src/pm/react/react.tsx | 2 +- packages/static-renderer/tsup.config.ts | 6 +- .../static-renderer/json-string.spec.ts | 272 ++++++++++++++++++ tests/cypress/tsconfig.json | 4 +- 6 files changed, 310 insertions(+), 31 deletions(-) create mode 100644 tests/cypress/integration/static-renderer/json-string.spec.ts diff --git a/packages/static-renderer/src/json/html-string/string.ts b/packages/static-renderer/src/json/html-string/string.ts index 77b645de4a7..4a2fc13713e 100644 --- a/packages/static-renderer/src/json/html-string/string.ts +++ b/packages/static-renderer/src/json/html-string/string.ts @@ -21,3 +21,28 @@ TNodeType extends { return ctx.component(ctx.props as any) }, options) } + +/** + * 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 { + const output = Object.entries(attrs) + .map(([key, value]) => `${key.split(' ').at(-1)}=${JSON.stringify(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('') +} diff --git a/packages/static-renderer/src/pm/html-string/html-string.ts b/packages/static-renderer/src/pm/html-string/html-string.ts index 69e24fc5c2e..6bece830b46 100644 --- a/packages/static-renderer/src/pm/html-string/html-string.ts +++ b/packages/static-renderer/src/pm/html-string/html-string.ts @@ -2,34 +2,14 @@ import type { DOMOutputSpecArray, Extensions, JSONContent } from '@tiptap/core' import type { DOMOutputSpec, Mark, Node } from '@tiptap/pm/model' -import { renderJSONContentToString } from '../../json/html-string/string.js' +import { renderJSONContentToString, serializeAttrsToHTMLString, serializeChildrenToHTMLString } from '../../json/html-string/string.js' import { TiptapStaticRendererOptions } from '../../json/renderer.js' import { renderToElement } from '../extensionRenderer.js' -/** - * 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 { - const output = Object.entries(attrs) - .map(([key, value]) => `${key.split(' ').at(-1)}=${JSON.stringify(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('') -} +export { + serializeAttrsToHTMLString, + serializeChildrenToHTMLString, +} from '../../json/html-string/string.js' /** * Take a DOMOutputSpec and return a function that can render it to a string @@ -87,7 +67,7 @@ export function domOutputSpecToHTMLString( // TODO support DOM elements? How to handle them? throw new Error( - '[tiptap error]: Unsupported DomOutputSpec type, check the `renderHTML` method output', + '[tiptap error]: Unsupported DomOutputSpec type, check the `renderHTML` method output or implement a node mapping', { cause: content, }, diff --git a/packages/static-renderer/src/pm/react/react.tsx b/packages/static-renderer/src/pm/react/react.tsx index e966c9a3c91..d97dba94c23 100644 --- a/packages/static-renderer/src/pm/react/react.tsx +++ b/packages/static-renderer/src/pm/react/react.tsx @@ -113,7 +113,7 @@ export function domOutputSpecToReactElement( // TODO support DOM elements? How to handle them? throw new Error( - '[tiptap error]: Unsupported DomOutputSpec type, check the `renderHTML` method output', + '[tiptap error]: Unsupported DomOutputSpec type, check the `renderHTML` method output or implement a node mapping', { cause: content, }, diff --git a/packages/static-renderer/tsup.config.ts b/packages/static-renderer/tsup.config.ts index 461a1a88f3e..d4ae1174f89 100644 --- a/packages/static-renderer/tsup.config.ts +++ b/packages/static-renderer/tsup.config.ts @@ -2,13 +2,13 @@ import { defineConfig } from 'tsup' export default defineConfig( [ - 'src/index.ts', - 'src/json/renderer.ts', - 'src/json/react/index.ts', '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', ].map(entry => ({ entry: [entry], tsconfig: '../../tsconfig.build.json', diff --git a/tests/cypress/integration/static-renderer/json-string.spec.ts b/tests/cypress/integration/static-renderer/json-string.spec.ts new file mode 100644 index 00000000000..806b081d743 --- /dev/null +++ b/tests/cypress/integration/static-renderer/json-string.spec.ts @@ -0,0 +1,272 @@ +/// + +import { TextType } from '@tiptap/core' +import Bold from '@tiptap/extension-bold' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import { Node } from '@tiptap/pm/model' +import { renderJSONContentToString, serializeChildrenToHTMLString } from '@tiptap/static-renderer/json/html-string' +import { renderToHTMLString } from '@tiptap/static-renderer/pm/html-string' + +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 `${serializeChildrenToHTMLString(children)}` + }, + paragraph: ({ children }) => { + return `

${serializeChildrenToHTMLString(children)}

` + }, + text: ({ node }) => { + return (node as unknown as TextType).text + }, + }, + markMapping: {}, + })({ content: json }) + + expect(html).to.eq('

Example Text

') + }) + + 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 `${serializeChildrenToHTMLString(children)}` + }, + paragraph: ({ children }) => { + return `

${serializeChildrenToHTMLString(children)}

` + }, + text: ({ node }) => { + return (node as unknown as TextType).text + }, + }, + markMapping: { + bold: ({ children }) => { + return `${serializeChildrenToHTMLString(children)}` + }, + }, + })({ content: json }) + + expect(html).to.eq('

Example Text

') + }) + + 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).to.deep.eq(json) + return `${serializeChildrenToHTMLString(children)}` + }, + heading: ({ node, children }) => { + expect(node).to.deep.eq({ + type: 'heading', + attrs: { + level: 2, + }, + content: [{ + type: 'text', + text: 'Example Text', + marks: [{ + type: 'bold', + attrs: {}, + }], + }], + }) + return `${serializeChildrenToHTMLString(children)}` + }, + text: ({ node }) => { + expect(node).to.deep.eq({ + type: 'text', + text: 'Example Text', + marks: [{ + type: 'bold', + attrs: {}, + }], + }) + return (node as unknown as TextType).text + }, + }, + markMapping: { + bold: ({ children, mark }) => { + expect(mark).to.deep.eq({ + type: 'bold', + attrs: {}, + }) + return `${serializeChildrenToHTMLString(children)}` + }, + }, + })({ content: json }) + + expect(html).to.eq('

Example Text

') + }) +}) + +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).to.eq('

Example Text

') + }) + + 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 `${serializeChildrenToHTMLString(children)}` + }, + }, + markMapping: { + bold: ({ children }) => { + return `${serializeChildrenToHTMLString(children)}` + }, + }, + }, + }) + + expect(html).to.eq('

Example Text

') + }) + + 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).to.eq('doc') + expect(node).to.be.instanceOf(Node) + return `${serializeChildrenToHTMLString(children)}` + }, + }, + markMapping: { + bold: ({ children, mark }) => { + expect(mark.type.name).to.eq('bold') + expect(mark).to.be.instanceOf(Node) + return `${serializeChildrenToHTMLString(children)}` + }, + }, + }, + }) + + expect(html).to.eq('

Example Text

') + }) +}) diff --git a/tests/cypress/tsconfig.json b/tests/cypress/tsconfig.json index 7967d1ebd80..29b1656751e 100644 --- a/tests/cypress/tsconfig.json +++ b/tests/cypress/tsconfig.json @@ -1,13 +1,15 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "moduleResolution": "bundler", "strict": false, "noEmit": false, "sourceMap": false, "types": ["cypress", "react", "react-dom"], "paths": { "@tiptap/*": ["packages/*/src", "packages/*/dist"], - "@tiptap/pm/*": ["../../pm/*/dist"] + "@tiptap/pm/*": ["../../pm/*/dist"], + "@tiptap/static-renderer/pm": ["../../static-renderer/dist/pm/*"], }, "typeRoots": ["../../node_modules/@types", "../../node_modules/"], }, From 33813ae64a8dd824abc1f0eaadf7d30bb66ef0f5 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 31 Dec 2024 15:56:40 +0100 Subject: [PATCH 19/31] refactor: minor cleanup --- package-lock.json | 3 --- packages/react/src/NodeViewContent.tsx | 1 - packages/static-renderer/package.json | 3 --- packages/static-renderer/src/json/html-string/string.ts | 4 ++-- 4 files changed, 2 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2716b6d2578..575e19f98f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19845,10 +19845,7 @@ "license": "MIT", "devDependencies": { "@tiptap/core": "^3.0.0-next.1", - "@tiptap/extension-text-align": "^3.0.0-next.1", - "@tiptap/extension-text-style": "^3.0.0-next.1", "@tiptap/pm": "^3.0.0-next.1", - "@tiptap/starter-kit": "^3.0.0-next.1", "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", diff --git a/packages/react/src/NodeViewContent.tsx b/packages/react/src/NodeViewContent.tsx index 576a03da583..031277cfcb6 100644 --- a/packages/react/src/NodeViewContent.tsx +++ b/packages/react/src/NodeViewContent.tsx @@ -3,7 +3,6 @@ import React, { ComponentProps } from 'react' import { useReactNodeView } from './useReactNodeView.js' export type NodeViewContentProps = { - // eslint-disable-next-line no-undef as?: NoInfer; } & ComponentProps diff --git a/packages/static-renderer/package.json b/packages/static-renderer/package.json index 1872bc4ac2b..0e89e88bb1e 100644 --- a/packages/static-renderer/package.json +++ b/packages/static-renderer/package.json @@ -72,9 +72,6 @@ "dist" ], "devDependencies": { - "@tiptap/extension-text-style": "^3.0.0-next.1", - "@tiptap/extension-text-align": "^3.0.0-next.1", - "@tiptap/starter-kit": "^3.0.0-next.1", "@tiptap/core": "^3.0.0-next.1", "@tiptap/pm": "^3.0.0-next.1", "@types/react": "^18.2.14", diff --git a/packages/static-renderer/src/json/html-string/string.ts b/packages/static-renderer/src/json/html-string/string.ts index 4a2fc13713e..48771332378 100644 --- a/packages/static-renderer/src/json/html-string/string.ts +++ b/packages/static-renderer/src/json/html-string/string.ts @@ -27,8 +27,8 @@ TNodeType extends { * @param attrs The attributes to serialize * @returns The serialized attributes as a string */ -export function serializeAttrsToHTMLString(attrs: Record): string { - const output = Object.entries(attrs) +export function serializeAttrsToHTMLString(attrs: Record | undefined | null): string { + const output = Object.entries(attrs || {}) .map(([key, value]) => `${key.split(' ').at(-1)}=${JSON.stringify(value)}`) .join(' ') From 5ad7ca9353ed7c08d8259c05d4000c9c7b177ada Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 31 Dec 2024 16:06:00 +0100 Subject: [PATCH 20/31] test: update tests --- tests/cypress/integration/static-renderer/json-string.spec.ts | 4 ++-- tests/cypress/tsconfig.json | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/cypress/integration/static-renderer/json-string.spec.ts b/tests/cypress/integration/static-renderer/json-string.spec.ts index 806b081d743..f4d6e001ea5 100644 --- a/tests/cypress/integration/static-renderer/json-string.spec.ts +++ b/tests/cypress/integration/static-renderer/json-string.spec.ts @@ -5,7 +5,7 @@ import Bold from '@tiptap/extension-bold' import Document from '@tiptap/extension-document' import Paragraph from '@tiptap/extension-paragraph' import Text from '@tiptap/extension-text' -import { Node } from '@tiptap/pm/model' +import { Mark, Node } from '@tiptap/pm/model' import { renderJSONContentToString, serializeChildrenToHTMLString } from '@tiptap/static-renderer/json/html-string' import { renderToHTMLString } from '@tiptap/static-renderer/pm/html-string' @@ -260,7 +260,7 @@ describe('static render json to string (with prosemirror)', () => { markMapping: { bold: ({ children, mark }) => { expect(mark.type.name).to.eq('bold') - expect(mark).to.be.instanceOf(Node) + expect(mark).to.be.instanceOf(Mark) return `${serializeChildrenToHTMLString(children)}` }, }, diff --git a/tests/cypress/tsconfig.json b/tests/cypress/tsconfig.json index 29b1656751e..8eedd4ce8b8 100644 --- a/tests/cypress/tsconfig.json +++ b/tests/cypress/tsconfig.json @@ -10,6 +10,7 @@ "@tiptap/*": ["packages/*/src", "packages/*/dist"], "@tiptap/pm/*": ["../../pm/*/dist"], "@tiptap/static-renderer/pm": ["../../static-renderer/dist/pm/*"], + "@tiptap/static-renderer/json": ["../../static-renderer/dist/json/*"], }, "typeRoots": ["../../node_modules/@types", "../../node_modules/"], }, From 5653a7258706ef4230437ce03b369a82c18bc50b Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 31 Dec 2024 16:13:23 +0100 Subject: [PATCH 21/31] chore: build all packages --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8051a3f0dc6..5b73393588c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -101,7 +101,7 @@ jobs: - name: Try to build the packages id: build-packages - run: npm run build:pm + run: npm run build - name: Test ${{ matrix.test-spec.name }} id: cypress From e21f0cd02179639a78f69a69e76e7d326a043602 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 6 Jan 2025 10:45:59 +0100 Subject: [PATCH 22/31] build: update build --- packages/static-renderer/package.json | 4 +-- .../static-renderer/src/helpers.example.ts | 35 ------------------- 2 files changed, 2 insertions(+), 37 deletions(-) delete mode 100644 packages/static-renderer/src/helpers.example.ts diff --git a/packages/static-renderer/package.json b/packages/static-renderer/package.json index 0e89e88bb1e..b048c825997 100644 --- a/packages/static-renderer/package.json +++ b/packages/static-renderer/package.json @@ -95,7 +95,7 @@ "directory": "packages/static-renderer" }, "scripts": { - "clean": "rm -rf dist", - "build": "npm run clean && tsup" + "build": "tsup", + "lint": "prettier ./src/ --check && eslint --cache --quiet --no-error-on-unmatched-pattern ./src/" } } diff --git a/packages/static-renderer/src/helpers.example.ts b/packages/static-renderer/src/helpers.example.ts deleted file mode 100644 index 7a8fff9b6a6..00000000000 --- a/packages/static-renderer/src/helpers.example.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { getAttributesFromExtensions, 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 { getAttributes } from './helpers.js' - -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, -) - -// eslint-disable-next-line no-console -console.log(attributes) From 5d9f6dcec8ca872593a7997c0d5b654405c49d2f Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 6 Jan 2025 10:46:16 +0100 Subject: [PATCH 23/31] test: add demos and tests to demos --- .../GenerateHTML/React/index.spec.js | 6 ++- .../GenerateHTML/Vue/index.spec.js | 6 ++- .../GenerateJSON/React/index.spec.js | 31 ++++++++++- .../GenerateJSON/Vue/index.spec.js | 31 ++++++++++- .../StaticRenderHTML/React/index.html | 0 .../StaticRenderHTML/React/index.spec.js | 11 ++++ .../StaticRenderHTML/React/index.tsx | 51 ++++++++++++++++++ .../StaticRenderHTML/Vue/index.html | 0 .../StaticRenderHTML/Vue/index.spec.js | 11 ++++ .../StaticRenderHTML/Vue/index.vue | 52 +++++++++++++++++++ .../StaticRenderReact/React/index.html | 0 .../StaticRenderReact/React/index.spec.js | 13 +++++ .../StaticRenderReact/React/index.tsx | 50 ++++++++++++++++++ 13 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 demos/src/GuideContent/StaticRenderHTML/React/index.html create mode 100644 demos/src/GuideContent/StaticRenderHTML/React/index.spec.js create mode 100644 demos/src/GuideContent/StaticRenderHTML/React/index.tsx create mode 100644 demos/src/GuideContent/StaticRenderHTML/Vue/index.html create mode 100644 demos/src/GuideContent/StaticRenderHTML/Vue/index.spec.js create mode 100644 demos/src/GuideContent/StaticRenderHTML/Vue/index.vue create mode 100644 demos/src/GuideContent/StaticRenderReact/React/index.html create mode 100644 demos/src/GuideContent/StaticRenderReact/React/index.spec.js create mode 100644 demos/src/GuideContent/StaticRenderReact/React/index.tsx diff --git a/demos/src/GuideContent/GenerateHTML/React/index.spec.js b/demos/src/GuideContent/GenerateHTML/React/index.spec.js index ffed7d12444..3ed466d3109 100644 --- a/demos/src/GuideContent/GenerateHTML/React/index.spec.js +++ b/demos/src/GuideContent/GenerateHTML/React/index.spec.js @@ -3,5 +3,9 @@ context('/src/GuideContent/GenerateHTML/React/', () => { cy.visit('/src/GuideContent/GenerateHTML/React/') }) - // TODO: Write tests + it('should render the content as an HTML string', () => { + cy.get('pre code').should('exist') + + cy.get('pre code').should('contain', '

Example Text

') + }) }) diff --git a/demos/src/GuideContent/GenerateHTML/Vue/index.spec.js b/demos/src/GuideContent/GenerateHTML/Vue/index.spec.js index 71388665409..360fc7a44f5 100644 --- a/demos/src/GuideContent/GenerateHTML/Vue/index.spec.js +++ b/demos/src/GuideContent/GenerateHTML/Vue/index.spec.js @@ -3,5 +3,9 @@ context('/src/GuideContent/GenerateHTML/Vue/', () => { cy.visit('/src/GuideContent/GenerateHTML/Vue/') }) - // TODO: Write tests + it('should render the content as an HTML string', () => { + cy.get('pre code').should('exist') + + cy.get('pre code').should('contain', '

Example Text

') + }) }) diff --git a/demos/src/GuideContent/GenerateJSON/React/index.spec.js b/demos/src/GuideContent/GenerateJSON/React/index.spec.js index 20a7040996d..f7ca0baccb1 100644 --- a/demos/src/GuideContent/GenerateJSON/React/index.spec.js +++ b/demos/src/GuideContent/GenerateJSON/React/index.spec.js @@ -3,5 +3,34 @@ context('/src/GuideContent/GenerateJSON/React/', () => { cy.visit('/src/GuideContent/GenerateJSON/React/') }) - // TODO: Write tests + it('should render the content as an HTML string', () => { + cy.get('pre code').should('exist') + + cy.get('pre code').should( + 'contain', + `{ + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Example " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "Text" + } + ] + } + ] +}`, + ) + }) }) diff --git a/demos/src/GuideContent/GenerateJSON/Vue/index.spec.js b/demos/src/GuideContent/GenerateJSON/Vue/index.spec.js index 6c9caed2ad0..295fb658a8e 100644 --- a/demos/src/GuideContent/GenerateJSON/Vue/index.spec.js +++ b/demos/src/GuideContent/GenerateJSON/Vue/index.spec.js @@ -3,5 +3,34 @@ context('/src/GuideContent/GenerateJSON/Vue/', () => { cy.visit('/src/GuideContent/GenerateJSON/Vue/') }) - // TODO: Write tests + it('should render the content as an HTML string', () => { + cy.get('pre code').should('exist') + + cy.get('pre code').should( + 'contain', + `{ + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Example " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "Text" + } + ] + } + ] +}`, + ) + }) }) diff --git a/demos/src/GuideContent/StaticRenderHTML/React/index.html b/demos/src/GuideContent/StaticRenderHTML/React/index.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/demos/src/GuideContent/StaticRenderHTML/React/index.spec.js b/demos/src/GuideContent/StaticRenderHTML/React/index.spec.js new file mode 100644 index 00000000000..156ca25c585 --- /dev/null +++ b/demos/src/GuideContent/StaticRenderHTML/React/index.spec.js @@ -0,0 +1,11 @@ +context('/src/GuideContent/StaticRenderHTML/React/', () => { + before(() => { + cy.visit('/src/GuideContent/StaticRenderHTML/React/') + }) + + it('should render the content as an HTML string', () => { + cy.get('pre code').should('exist') + + cy.get('pre code').should('contain', '

Example Text

') + }) +}) diff --git a/demos/src/GuideContent/StaticRenderHTML/React/index.tsx b/demos/src/GuideContent/StaticRenderHTML/React/index.tsx new file mode 100644 index 00000000000..72d2c1b186d --- /dev/null +++ b/demos/src/GuideContent/StaticRenderHTML/React/index.tsx @@ -0,0 +1,51 @@ +import Bold from '@tiptap/extension-bold' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import { renderToHTMLString } from '@tiptap/static-renderer' +import React, { useMemo } from 'react' + +const json = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example ', + }, + { + type: 'text', + marks: [ + { + type: 'bold', + }, + ], + text: 'Text', + }, + ], + }, + ], +} + +export default () => { + const output = useMemo(() => { + return renderToHTMLString({ + content: json, + extensions: [ + Document, + Paragraph, + Text, + Bold, + // other extensions … + ], + }) + }, []) + + return ( +
+      {output}
+    
+ ) +} diff --git a/demos/src/GuideContent/StaticRenderHTML/Vue/index.html b/demos/src/GuideContent/StaticRenderHTML/Vue/index.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/demos/src/GuideContent/StaticRenderHTML/Vue/index.spec.js b/demos/src/GuideContent/StaticRenderHTML/Vue/index.spec.js new file mode 100644 index 00000000000..360fc7a44f5 --- /dev/null +++ b/demos/src/GuideContent/StaticRenderHTML/Vue/index.spec.js @@ -0,0 +1,11 @@ +context('/src/GuideContent/GenerateHTML/Vue/', () => { + before(() => { + cy.visit('/src/GuideContent/GenerateHTML/Vue/') + }) + + it('should render the content as an HTML string', () => { + cy.get('pre code').should('exist') + + cy.get('pre code').should('contain', '

Example Text

') + }) +}) diff --git a/demos/src/GuideContent/StaticRenderHTML/Vue/index.vue b/demos/src/GuideContent/StaticRenderHTML/Vue/index.vue new file mode 100644 index 00000000000..a62bab2fb20 --- /dev/null +++ b/demos/src/GuideContent/StaticRenderHTML/Vue/index.vue @@ -0,0 +1,52 @@ + + + diff --git a/demos/src/GuideContent/StaticRenderReact/React/index.html b/demos/src/GuideContent/StaticRenderReact/React/index.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/demos/src/GuideContent/StaticRenderReact/React/index.spec.js b/demos/src/GuideContent/StaticRenderReact/React/index.spec.js new file mode 100644 index 00000000000..b5baa335275 --- /dev/null +++ b/demos/src/GuideContent/StaticRenderReact/React/index.spec.js @@ -0,0 +1,13 @@ +context('/src/GuideContent/StaticRenderReact/React/', () => { + before(() => { + cy.visit('/src/GuideContent/StaticRenderReact/React/') + }) + + it('should render the content as HTML', () => { + cy.get('p').should('exist') + cy.get('p').should('contain', 'Example') + + cy.get('p strong').should('exist') + cy.get('p strong').should('contain', 'Text') + }) +}) diff --git a/demos/src/GuideContent/StaticRenderReact/React/index.tsx b/demos/src/GuideContent/StaticRenderReact/React/index.tsx new file mode 100644 index 00000000000..8aa8456ee92 --- /dev/null +++ b/demos/src/GuideContent/StaticRenderReact/React/index.tsx @@ -0,0 +1,50 @@ +import Bold from '@tiptap/extension-bold' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import { renderToReactElement } from '@tiptap/static-renderer' +import React, { useMemo } from 'react' + +const json = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example ', + }, + { + type: 'text', + marks: [ + { + type: 'bold', + }, + ], + text: 'Text', + }, + ], + }, + ], +} + +/** + * This will statically render the JSON into React elements, which can be directly rendered in the DOM (even on the server). + */ +export default () => { + const output = useMemo(() => { + return renderToReactElement({ + content: json, + extensions: [ + Document, + Paragraph, + Text, + Bold, + // other extensions … + ], + }) + }, []) + + return
{output}
+} From 542b01daea78360d1faa529b8e6794efaf2ad6da Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 6 Jan 2025 10:47:22 +0100 Subject: [PATCH 24/31] chore: fix for react --- demos/src/GuideContent/GenerateHTML/React/index.jsx | 2 +- demos/src/GuideContent/GenerateJSON/React/index.jsx | 2 +- demos/src/GuideContent/GenerateText/React/index.jsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/demos/src/GuideContent/GenerateHTML/React/index.jsx b/demos/src/GuideContent/GenerateHTML/React/index.jsx index ce2d844a22b..f3122aa15ba 100644 --- a/demos/src/GuideContent/GenerateHTML/React/index.jsx +++ b/demos/src/GuideContent/GenerateHTML/React/index.jsx @@ -41,7 +41,7 @@ export default () => { Bold, // other extensions … ]) - }, [json]) + }, []) return (
diff --git a/demos/src/GuideContent/GenerateJSON/React/index.jsx b/demos/src/GuideContent/GenerateJSON/React/index.jsx
index cc5e12f4344..fd6c7c309b8 100644
--- a/demos/src/GuideContent/GenerateJSON/React/index.jsx
+++ b/demos/src/GuideContent/GenerateJSON/React/index.jsx
@@ -19,7 +19,7 @@ export default () => {
       Bold,
       // other extensions …
     ])
-  }, [html])
+  }, [])
 
   return (
     
diff --git a/demos/src/GuideContent/GenerateText/React/index.jsx b/demos/src/GuideContent/GenerateText/React/index.jsx
index cc4b35e1216..ba95ed6b60a 100644
--- a/demos/src/GuideContent/GenerateText/React/index.jsx
+++ b/demos/src/GuideContent/GenerateText/React/index.jsx
@@ -52,7 +52,7 @@ export default () => {
         blockSeparator: '\n\n',
       },
     )
-  }, [json])
+  }, [])
 
   return (
     

From ffe5b9a9a9725cf3087fb4cd874c4e919e2524c5 Mon Sep 17 00:00:00 2001
From: Nick the Sick 
Date: Mon, 6 Jan 2025 12:15:46 +0100
Subject: [PATCH 25/31] docs: add demo for static renderer

---
 .../Examples/StaticRendering/React/index.html |   0
 .../StaticRendering/React/index.spec.js       |  12 +
 .../Examples/StaticRendering/React/index.tsx  | 324 ++++++++++++++++++
 .../StaticRendering/React/styles.scss         |  92 +++++
 .../src/json/html-string/string.ts            |  24 +-
 5 files changed, 440 insertions(+), 12 deletions(-)
 create mode 100644 demos/src/Examples/StaticRendering/React/index.html
 create mode 100644 demos/src/Examples/StaticRendering/React/index.spec.js
 create mode 100644 demos/src/Examples/StaticRendering/React/index.tsx
 create mode 100644 demos/src/Examples/StaticRendering/React/styles.scss

diff --git a/demos/src/Examples/StaticRendering/React/index.html b/demos/src/Examples/StaticRendering/React/index.html
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/demos/src/Examples/StaticRendering/React/index.spec.js b/demos/src/Examples/StaticRendering/React/index.spec.js
new file mode 100644
index 00000000000..fab3fcfed00
--- /dev/null
+++ b/demos/src/Examples/StaticRendering/React/index.spec.js
@@ -0,0 +1,12 @@
+context('/src/Examples/StaticRendering/React/', () => {
+  beforeEach(() => {
+    cy.visit('/src/Examples/StaticRendering/React/')
+  })
+
+  it('should have a working tiptap instance', () => {
+    cy.get('.tiptap').then(([{ editor }]) => {
+      // eslint-disable-next-line
+      expect(editor).to.not.be.null
+    })
+  })
+})
diff --git a/demos/src/Examples/StaticRendering/React/index.tsx b/demos/src/Examples/StaticRendering/React/index.tsx
new file mode 100644
index 00000000000..baf16598b0e
--- /dev/null
+++ b/demos/src/Examples/StaticRendering/React/index.tsx
@@ -0,0 +1,324 @@
+import './styles.scss'
+
+import { Color } from '@tiptap/extension-color'
+import ListItem from '@tiptap/extension-list-item'
+import TextStyle from '@tiptap/extension-text-style'
+import { EditorProvider, JSONContent, useCurrentEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import { renderToHTMLString, renderToReactElement } from '@tiptap/static-renderer'
+import React, { useState } from 'react'
+
+const extensions = [StarterKit, Color.configure({ types: [TextStyle.name, ListItem.name] }), TextStyle]
+
+const content = `
+

+ Hi there, +

+

+ this is a basic example of Tiptap. Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists: +

+
    +
  • + That’s a bullet list with one … +
  • +
  • + … or two list items. +
  • +
+

+ Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block: +

+
body {
+  display: none;
+}
+

+ 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. +

+
+ Wow, that’s amazing. Good work, boy! 👏 +
+ — Mom +
+` + +export default () => { + const [tab, setTab] = useState<'react' | 'html' | 'html-element'>('react') + const [currentJSON, setJSON] = useState(null) + return ( +
+ } + extensions={extensions} + content={content} + onUpdate={({ editor }) => { + setJSON(editor.getJSON()) + }} + > + +
+

Rendered as:

+
+ + + +
+
+ {tab === 'react' && ( +
+

React Element

+

This example renders the JSON content directly into a React element without using an editor instance.

+

Notice that every paragraph now has a button counter

+
+ {currentJSON && + renderToReactElement({ + content: currentJSON, + extensions, + options: { + nodeMapping: { + paragraph: ({ node }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [count, setCount] = useState(0) + return ( + <> + +

Count is: {count}

+

{node.textContent}

+ + ) + }, + }, + }, + })} +
+
+ )} + {tab === 'html' && ( +
+

HTML String

+

+ This example renders the JSON content into an HTML string without using an editor instance or document + parser. +

+
+            
+              {currentJSON &&
+                renderToHTMLString({
+                  content: currentJSON,
+                  extensions,
+                })}
+            
+          
+
+ )} + {tab === 'html-element' && ( +
+

To HTML Element (via dangerouslySetInnerHTML)

+

+ This example renders the JSON content into an HTML string without using an editor instance or document + parser, and places that result directly into the HTML using dangerouslySetInnerHTML. +

+
+
+ )} +
+ ) +} + +function MenuBar() { + const { editor } = useCurrentEditor() + + const editorState = useEditorState({ + editor: editor!, + selector: ctx => { + return { + isBold: ctx.editor.isActive('bold'), + canBold: ctx.editor.can().chain().focus().toggleBold().run(), + isItalic: ctx.editor.isActive('italic'), + canItalic: ctx.editor.can().chain().focus().toggleItalic().run(), + isStrike: ctx.editor.isActive('strike'), + canStrike: ctx.editor.can().chain().focus().toggleStrike().run(), + isCode: ctx.editor.isActive('code'), + canCode: ctx.editor.can().chain().focus().toggleCode().run(), + canClearMarks: ctx.editor.can().chain().focus().unsetAllMarks().run(), + isParagraph: ctx.editor.isActive('paragraph'), + isHeading1: ctx.editor.isActive('heading', { level: 1 }), + isHeading2: ctx.editor.isActive('heading', { level: 2 }), + isHeading3: ctx.editor.isActive('heading', { level: 3 }), + isHeading4: ctx.editor.isActive('heading', { level: 4 }), + isHeading5: ctx.editor.isActive('heading', { level: 5 }), + isHeading6: ctx.editor.isActive('heading', { level: 6 }), + isBulletList: ctx.editor.isActive('bulletList'), + isOrderedList: ctx.editor.isActive('orderedList'), + isCodeBlock: ctx.editor.isActive('codeBlock'), + isBlockquote: ctx.editor.isActive('blockquote'), + canUndo: ctx.editor.can().chain().focus().undo().run(), + canRedo: ctx.editor.can().chain().focus().redo().run(), + isPurple: ctx.editor.isActive('textStyle', { color: '#958DF1' }), + } + }, + }) + + if (!editor) { + return null + } + + return ( +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ ) +} diff --git a/demos/src/Examples/StaticRendering/React/styles.scss b/demos/src/Examples/StaticRendering/React/styles.scss new file mode 100644 index 00000000000..ae82a937a89 --- /dev/null +++ b/demos/src/Examples/StaticRendering/React/styles.scss @@ -0,0 +1,92 @@ +/* Basic editor styles */ +.tiptap { + :first-child { + margin-top: 0; + } + + /* List styles */ + ul, + ol { + padding: 0 1rem; + margin: 1.25rem 1rem 1.25rem 0.4rem; + + li p { + margin-top: 0.25em; + margin-bottom: 0.25em; + } + } + + /* Heading styles */ + h1, + h2, + h3, + h4, + h5, + h6 { + line-height: 1.1; + margin-top: 2.5rem; + text-wrap: pretty; + } + + h1, + h2 { + margin-top: 3.5rem; + margin-bottom: 1.5rem; + } + + h1 { + font-size: 1.4rem; + } + + h2 { + font-size: 1.2rem; + } + + h3 { + font-size: 1.1rem; + } + + h4, + h5, + h6 { + font-size: 1rem; + } + + /* Code and preformatted text styles */ + code { + background-color: var(--purple-light); + border-radius: 0.4rem; + color: var(--black); + font-size: 0.85rem; + padding: 0.25em 0.3em; + } + + pre { + background: var(--black); + border-radius: 0.5rem; + color: var(--white); + font-family: 'JetBrainsMono', monospace; + margin: 1.5rem 0; + padding: 0.75rem 1rem; + overflow-x: auto; + + code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; + } + } + + blockquote { + border-left: 3px solid var(--gray-3); + margin: 1.5rem 0; + padding-left: 1rem; + } + + hr { + border: none; + border-top: 1px solid var(--gray-2); + margin: 2rem 0; + } +} diff --git a/packages/static-renderer/src/json/html-string/string.ts b/packages/static-renderer/src/json/html-string/string.ts index 48771332378..b6830dbe1c5 100644 --- a/packages/static-renderer/src/json/html-string/string.ts +++ b/packages/static-renderer/src/json/html-string/string.ts @@ -4,18 +4,18 @@ import type { MarkType, NodeType } from '@tiptap/core' import { TiptapStaticRenderer, TiptapStaticRendererOptions } 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 } = 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 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, >(options: TiptapStaticRendererOptions) { return TiptapStaticRenderer(ctx => { return ctx.component(ctx.props as any) From addb3dae8d8fa036afc64622ef2e0b45578b7f20 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 6 Jan 2025 12:29:46 +0100 Subject: [PATCH 26/31] chore: lint --- .changeset/dirty-colts-shave.md | 8 +- .changeset/fair-jars-shout.md | 2 +- .../core/src/helpers/flattenExtensions.ts | 6 +- packages/extension-utils/CHANGELOG.md | 1 - packages/extension-utils/README.md | 2 +- packages/static-renderer/README.md | 2 +- packages/static-renderer/src/helpers.ts | 20 +- .../src/json/react/react.example.ts | 53 +++-- .../static-renderer/src/json/react/react.tsx | 25 +- packages/static-renderer/src/json/renderer.ts | 99 ++++---- .../src/pm/extensionRenderer.ts | 50 ++-- .../src/pm/html-string/html-string.example.ts | 3 +- .../src/pm/html-string/html-string.ts | 41 ++-- .../src/pm/markdown/markdown.example.ts | 22 +- .../src/pm/markdown/markdown.ts | 10 +- .../src/pm/react/react.example.tsx | 17 +- .../static-renderer/src/pm/react/react.tsx | 52 ++--- .../static-renderer/json-string.spec.ts | 214 ++++++++++-------- tests/cypress/tsconfig.json | 2 +- 19 files changed, 307 insertions(+), 322 deletions(-) diff --git a/.changeset/dirty-colts-shave.md b/.changeset/dirty-colts-shave.md index a3dcb1a7119..32f0e2ccf31 100644 --- a/.changeset/dirty-colts-shave.md +++ b/.changeset/dirty-colts-shave.md @@ -1,8 +1,8 @@ --- -"@tiptap/extension-table-header": minor -"@tiptap/extension-table-cell": minor -"@tiptap/extension-table-row": minor -"@tiptap/extension-table": minor +'@tiptap/extension-table-header': minor +'@tiptap/extension-table-cell': minor +'@tiptap/extension-table-row': minor +'@tiptap/extension-table': minor --- This change repackages all of the table extensions to be within the `@tiptap/extension-table` package (other packages are just a re-export of the `@tiptap/extension-table` package). It also adds the `TableKit` export which will allow configuring the entire table with one extension. diff --git a/.changeset/fair-jars-shout.md b/.changeset/fair-jars-shout.md index 32de80a86c8..f24f0ab2bd6 100644 --- a/.changeset/fair-jars-shout.md +++ b/.changeset/fair-jars-shout.md @@ -1,5 +1,5 @@ --- -"@tiptap/extension-text-style": patch +'@tiptap/extension-text-style': patch --- The text-style extension, now will match elements with a style tag, but not consume them to allow other elements to match [per this comment](https://github.com/ueberdosis/tiptap/discussions/5912#discussioncomment-11716337). diff --git a/packages/core/src/helpers/flattenExtensions.ts b/packages/core/src/helpers/flattenExtensions.ts index 2c7bbcc1f16..024e06daa05 100644 --- a/packages/core/src/helpers/flattenExtensions.ts +++ b/packages/core/src/helpers/flattenExtensions.ts @@ -16,11 +16,7 @@ export function flattenExtensions(extensions: Extensions): Extensions { storage: extension.storage, } - const addExtensions = getExtensionField( - extension, - 'addExtensions', - context, - ) + const addExtensions = getExtensionField(extension, 'addExtensions', context) if (addExtensions) { return [extension, ...flattenExtensions(addExtensions())] diff --git a/packages/extension-utils/CHANGELOG.md b/packages/extension-utils/CHANGELOG.md index 293c5681ad9..420e6f23d0e 100644 --- a/packages/extension-utils/CHANGELOG.md +++ b/packages/extension-utils/CHANGELOG.md @@ -1,2 +1 @@ # Change Log - diff --git a/packages/extension-utils/README.md b/packages/extension-utils/README.md index ca8cb8479c8..bacc3fe5cb6 100644 --- a/packages/extension-utils/README.md +++ b/packages/extension-utils/README.md @@ -7,7 +7,7 @@ ## 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*. +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 diff --git a/packages/static-renderer/README.md b/packages/static-renderer/README.md index 492aa23b4b3..d6565a7980b 100644 --- a/packages/static-renderer/README.md +++ b/packages/static-renderer/README.md @@ -7,7 +7,7 @@ ## 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*. +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 diff --git a/packages/static-renderer/src/helpers.ts b/packages/static-renderer/src/helpers.ts index a05bcd9c01e..89a141a2419 100644 --- a/packages/static-renderer/src/helpers.ts +++ b/packages/static-renderer/src/helpers.ts @@ -1,8 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { - type ExtensionAttribute, type MarkType, type NodeType, - mergeAttributes, -} from '@tiptap/core' +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. @@ -34,19 +31,13 @@ export function getAttributes( .map(item => { if (!item.attribute.renderHTML) { return { - [item.name]: - item.name in nodeOrMarkAttributes - ? nodeOrMarkAttributes[item.name] - : item.attribute.default, + [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, + [item.name]: item.name in nodeOrMarkAttributes ? nodeOrMarkAttributes[item.name] : item.attribute.default, } ) }) @@ -58,9 +49,6 @@ export function getAttributes( * @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[], -) { +export function getHTMLAttributes(nodeOrMark: NodeType | MarkType, extensionAttributes: ExtensionAttribute[]) { return getAttributes(nodeOrMark, extensionAttributes, true) } diff --git a/packages/static-renderer/src/json/react/react.example.ts b/packages/static-renderer/src/json/react/react.example.ts index 6128d6a4c37..7c6fc99b5de 100644 --- a/packages/static-renderer/src/json/react/react.example.ts +++ b/packages/static-renderer/src/json/react/react.example.ts @@ -14,32 +14,31 @@ import { renderJSONContentToReactElement } from './react.js' */ // eslint-disable-next-line no-console -console.log(renderJSONContentToReactElement({ - nodeMapping: { - text({ node }) { - return (node as unknown as TextType).text ?? null - }, - heading({ - node, - children, - }: NodeProps, React.ReactNode>) { - const level = node.attrs.level - const hTag = `h${level}` +console.log( + renderJSONContentToReactElement({ + nodeMapping: { + text({ node }) { + return (node as unknown as TextType).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: {}, -})({ - content: { - type: 'heading', - content: [ - { - type: 'text', - text: 'hello world', - marks: [], + return React.createElement(hTag, node.attrs, children) }, - ], - attrs: { level: 2 }, - }, -})) + }, + markMapping: {}, + })({ + content: { + type: 'heading', + content: [ + { + type: 'text', + text: 'hello world', + marks: [], + }, + ], + attrs: { level: 2 }, + }, + }), +) diff --git a/packages/static-renderer/src/json/react/react.tsx b/packages/static-renderer/src/json/react/react.tsx index 8a77e6aa930..c3f384fb328 100644 --- a/packages/static-renderer/src/json/react/react.tsx +++ b/packages/static-renderer/src/json/react/react.tsx @@ -14,22 +14,19 @@ export function renderJSONContentToReactElement< * 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 }; + content?: { forEach: (cb: (node: TNodeType) => void) => void } + marks?: readonly TMarkType[] + type: string | { name: string } } = NodeType, >(options: TiptapStaticRendererOptions) { let key = 0 - return TiptapStaticRenderer( - ({ component, props: { children, ...props } }) => { - return React.createElement( - component as React.FC, - // eslint-disable-next-line no-plusplus - Object.assign(props, { key: key++ }), - ([] as React.ReactNode[]).concat(children), - ) - }, - options, - ) + return TiptapStaticRenderer(({ component, props: { children, ...props } }) => { + return React.createElement( + component as React.FC, + // eslint-disable-next-line no-plusplus + Object.assign(props, { key: key++ }), + ([] as React.ReactNode[]).concat(children), + ) + }, options) } diff --git a/packages/static-renderer/src/json/renderer.ts b/packages/static-renderer/src/json/renderer.ts index 26a0d58be54..cb6205e9316 100644 --- a/packages/static-renderer/src/json/renderer.ts +++ b/packages/static-renderer/src/json/renderer.ts @@ -8,15 +8,15 @@ export type NodeProps = { /** * The current node to render */ - node: TNodeType; + node: TNodeType /** * Unless the node is the root node, this will always be defined */ - parent?: TNodeType; + parent?: TNodeType /** * The children of the current node */ - children?: TChildren; + children?: TChildren /** * Render a child element */ @@ -24,13 +24,13 @@ export type NodeProps = { /** * Tiptap JSON content to render */ - content: TNodeType; + content: TNodeType /** * The parent node of the current node */ - parent?: TNodeType; - }) => TChildren; -}; + parent?: TNodeType + }) => TChildren +} /** * Props for a mark renderer @@ -39,20 +39,20 @@ export type MarkProps = { /** * The current mark to render */ - mark: TMarkType; + mark: TMarkType /** * The children of the current mark */ - children?: TChildren; + children?: TChildren /** * The node the current mark is applied to */ - node: TNodeType; + node: TNodeType /** * The node the current mark is applied to */ - parent?: TNodeType; -}; + parent?: TNodeType +} export type TiptapStaticRendererOptions< /** @@ -67,40 +67,40 @@ export type TiptapStaticRendererOptions< * 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 }; + 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 + 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 + ctx: MarkProps, ) => TReturnType, > = { /** * Mapping of node types to react components */ - nodeMapping: Record>; + nodeMapping: Record> /** * Mapping of mark types to react components */ - markMapping: Record>; + markMapping: Record> /** * Component to render if a node type is not handled */ - unhandledNode?: NoInfer; + unhandledNode?: NoInfer /** * Component to render if a mark type is not handled */ - unhandledMark?: NoInfer; -}; + unhandledMark?: NoInfer +} /** * Tiptap Static Renderer @@ -126,22 +126,21 @@ export function TiptapStaticRenderer< * 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 }; + 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 + 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 + ctx: MarkProps, ) => TReturnType, >( /** @@ -150,13 +149,13 @@ cb: (node: TNodeType) => void) => void }; renderComponent: ( ctx: | { - component: TNodeRender; - props: NodeProps; + component: TNodeRender + props: NodeProps } | { - component: TMarkRender; - props: MarkProps; - } + component: TMarkRender + props: MarkProps + }, ) => TReturnType, { nodeMapping, @@ -175,11 +174,11 @@ cb: (node: TNodeType) => void) => void }; /** * Tiptap JSON content to render */ - content: TNodeType; + content: TNodeType /** * The parent node of the current node */ - parent?: TNodeType; + parent?: TNodeType }): TReturnType { const nodeType = typeof content.type === 'string' ? content.type : content.type.name const NodeHandler = nodeMapping[nodeType] ?? unhandledNode @@ -218,23 +217,23 @@ cb: (node: TNodeType) => void) => void }; // 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 + 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}`) - } + if (!MarkHandler) { + throw new Error(`missing handler for mark type ${markType}`) + } - return renderComponent({ - component: MarkHandler, - props: { - mark, - parent, - node: content, - children: acc, - }, - }) - }, nodeContent) + return renderComponent({ + component: MarkHandler, + props: { + mark, + parent, + node: content, + children: acc, + }, + }) + }, nodeContent) : nodeContent return markedContent diff --git a/packages/static-renderer/src/pm/extensionRenderer.ts b/packages/static-renderer/src/pm/extensionRenderer.ts index b611df3b0a5..3614cf916ab 100644 --- a/packages/static-renderer/src/pm/extensionRenderer.ts +++ b/packages/static-renderer/src/pm/extensionRenderer.ts @@ -20,7 +20,7 @@ import { DOMOutputSpec, Mark, Node } from '@tiptap/pm/model' import { getHTMLAttributes } from '../helpers.js' import { MarkProps, NodeProps, TiptapStaticRendererOptions } from '../json/renderer.js' -export type DomOutputSpecToElement = (content: DOMOutputSpec) => (children?: T | T[]) => T; +export type DomOutputSpecToElement = (content: DOMOutputSpec) => (children?: T | T[]) => T /** * This takes a NodeExtension and maps it to a React component @@ -41,11 +41,7 @@ export function mapNodeExtensionToReactNode( parent: extension.parent, } - const renderToHTML = getExtensionField( - extension, - 'renderHTML', - context, - ) + const renderToHTML = getExtensionField(extension, 'renderHTML', context) if (!renderToHTML) { if (options?.unhandledNode) { @@ -102,11 +98,7 @@ export function mapMarkExtensionToReactNode( parent: extension.parent, } - const renderToHTML = getExtensionField( - extension, - 'renderHTML', - context, - ) + const renderToHTML = getExtensionField(extension, 'renderHTML', context) if (!renderToHTML) { if (options?.unhandledMark) { @@ -115,9 +107,7 @@ export function mapMarkExtensionToReactNode( return [ extension.name, () => { - throw new Error( - `Node ${extension.name} cannot be rendered, it is missing a "renderToHTML" method`, - ) + throw new Error(`Node ${extension.name} cannot be rendered, it is missing a "renderToHTML" method`) }, ] } @@ -162,15 +152,15 @@ export function renderToElement({ extensions, options, }: { - renderer: (options: TiptapStaticRendererOptions) => (ctx: { content: Node }) => T; - domOutputSpecToElement: DomOutputSpecToElement; + renderer: (options: TiptapStaticRendererOptions) => (ctx: { content: Node }) => T + domOutputSpecToElement: DomOutputSpecToElement mapDefinedTypes: { - doc: (props: NodeProps) => T; - text: (props: NodeProps) => T; - }; - content: Node | JSONContent; - extensions: Extensions; - options?: Partial>; + doc: (props: NodeProps) => T + text: (props: NodeProps) => T + } + content: Node | JSONContent + extensions: Extensions + options?: Partial> }): T { // get all extensions in order & split them into nodes and marks extensions = resolveExtensions(extensions) @@ -197,12 +187,9 @@ export function renderToElement({ } return true }) - .map(nodeExtension => mapNodeExtensionToReactNode( - domOutputSpecToElement, - nodeExtension, - extensionAttributes, - options, - )), + .map(nodeExtension => + mapNodeExtensionToReactNode(domOutputSpecToElement, nodeExtension, extensionAttributes, options), + ), ), ...mapDefinedTypes, ...options?.nodeMapping, @@ -217,12 +204,7 @@ export function renderToElement({ } return true }) - .map(mark => mapMarkExtensionToReactNode( - domOutputSpecToElement, - mark, - extensionAttributes, - options, - )), + .map(mark => mapMarkExtensionToReactNode(domOutputSpecToElement, mark, extensionAttributes, options)), ), ...options?.markMapping, }, diff --git a/packages/static-renderer/src/pm/html-string/html-string.example.ts b/packages/static-renderer/src/pm/html-string/html-string.example.ts index fdd695bb542..6079b829731 100644 --- a/packages/static-renderer/src/pm/html-string/html-string.example.ts +++ b/packages/static-renderer/src/pm/html-string/html-string.example.ts @@ -24,8 +24,7 @@ console.log( }, markMapping: {}, }, - content: - { + content: { type: 'doc', from: 0, to: 574, diff --git a/packages/static-renderer/src/pm/html-string/html-string.ts b/packages/static-renderer/src/pm/html-string/html-string.ts index 6bece830b46..2384d24a503 100644 --- a/packages/static-renderer/src/pm/html-string/html-string.ts +++ b/packages/static-renderer/src/pm/html-string/html-string.ts @@ -2,23 +2,22 @@ import type { DOMOutputSpecArray, Extensions, JSONContent } from '@tiptap/core' import type { DOMOutputSpec, Mark, Node } from '@tiptap/pm/model' -import { renderJSONContentToString, serializeAttrsToHTMLString, serializeChildrenToHTMLString } from '../../json/html-string/string.js' -import { TiptapStaticRendererOptions } from '../../json/renderer.js' -import { renderToElement } from '../extensionRenderer.js' - -export { +import { + renderJSONContentToString, serializeAttrsToHTMLString, serializeChildrenToHTMLString, } from '../../json/html-string/string.js' +import { TiptapStaticRendererOptions } from '../../json/renderer.js' +import { renderToElement } from '../extensionRenderer.js' + +export { serializeAttrsToHTMLString, serializeChildrenToHTMLString } from '../../json/html-string/string.js' /** * 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 { +export function domOutputSpecToHTMLString(content: DOMOutputSpec): (children?: string | string[]) => string { if (typeof content === 'string') { return () => content } @@ -45,23 +44,23 @@ export function domOutputSpecToHTMLString( if (children === 0) { return child => `<${tag}>${domOutputSpecToHTMLString(attrs as DOMOutputSpecArray)(child)}` } - return child => `<${tag}>${domOutputSpecToHTMLString(attrs as DOMOutputSpecArray)(child)}${[children] - .concat(rest) - .map(a => domOutputSpecToHTMLString(a)(child))}` + return child => + `<${tag}>${domOutputSpecToHTMLString(attrs as DOMOutputSpecArray)(child)}${[children] + .concat(rest) + .map(a => domOutputSpecToHTMLString(a)(child))}` } if (children === undefined) { return () => `<${tag}${serializeAttrsToHTMLString(attrs)}/>` } if (children === 0) { - return child => `<${tag}${serializeAttrsToHTMLString(attrs)}>${serializeChildrenToHTMLString( - child, - )}` + return child => `<${tag}${serializeAttrsToHTMLString(attrs)}>${serializeChildrenToHTMLString(child)}` } - return child => `<${tag}${serializeAttrsToHTMLString(attrs)}>${[children] - .concat(rest) - .map(a => domOutputSpecToHTMLString(a)(child)) - .join('')}` + return child => + `<${tag}${serializeAttrsToHTMLString(attrs)}>${[children] + .concat(rest) + .map(a => domOutputSpecToHTMLString(a)(child)) + .join('')}` } } @@ -86,9 +85,9 @@ export function renderToHTMLString({ extensions, options, }: { - content: Node | JSONContent; - extensions: Extensions; - options?: Partial>; + content: Node | JSONContent + extensions: Extensions + options?: Partial> }): string { return renderToElement({ renderer: renderJSONContentToString, diff --git a/packages/static-renderer/src/pm/markdown/markdown.example.ts b/packages/static-renderer/src/pm/markdown/markdown.example.ts index 67a52712286..8a14c91a80d 100644 --- a/packages/static-renderer/src/pm/markdown/markdown.example.ts +++ b/packages/static-renderer/src/pm/markdown/markdown.example.ts @@ -14,7 +14,18 @@ import { renderToMarkdown } from './markdown.js' // eslint-disable-next-line no-console console.log( renderToMarkdown({ - extensions: [StarterKit, Subscript, Superscript, TextAlign, TextStyle, Highlight, Table, TableRow, TableCell, TableHeader], + extensions: [ + StarterKit, + Subscript, + Superscript, + TextAlign, + TextStyle, + Highlight, + Table, + TableRow, + TableCell, + TableHeader, + ], content: { type: 'doc', from: 0, @@ -612,9 +623,7 @@ console.log( attrs: { colspan: 1, rowspan: 1, - colwidth: [ - 200, - ], + colwidth: [200], }, content: [ { @@ -639,10 +648,7 @@ console.log( attrs: { colspan: 3, rowspan: 1, - colwidth: [ - 150, - 100, - ], + colwidth: [150, 100], }, content: [ { diff --git a/packages/static-renderer/src/pm/markdown/markdown.ts b/packages/static-renderer/src/pm/markdown/markdown.ts index e107378bd5b..770585ca899 100644 --- a/packages/static-renderer/src/pm/markdown/markdown.ts +++ b/packages/static-renderer/src/pm/markdown/markdown.ts @@ -13,9 +13,9 @@ export function renderToMarkdown({ extensions, options, }: { - content: Node | JSONContent; - extensions: Extensions; - options?: Partial>; + content: Node | JSONContent + extensions: Extensions + options?: Partial> }) { return renderToHTMLString({ content, @@ -55,9 +55,7 @@ export function renderToMarkdown({ return `${new Array(level).fill('#').join('')} ${children}\n` }, codeBlock({ node, children }) { - return `\n\`\`\`${node.attrs.language}\n${serializeChildrenToHTMLString( - children, - )}\n\`\`\`\n` + return `\n\`\`\`${node.attrs.language}\n${serializeChildrenToHTMLString(children)}\n\`\`\`\n` }, blockquote({ children }) { return `\n${serializeChildrenToHTMLString(children) diff --git a/packages/static-renderer/src/pm/react/react.example.tsx b/packages/static-renderer/src/pm/react/react.example.tsx index 69bc845ebf6..caa13aa2b41 100644 --- a/packages/static-renderer/src/pm/react/react.example.tsx +++ b/packages/static-renderer/src/pm/react/react.example.tsx @@ -1,12 +1,7 @@ /* eslint-disable no-plusplus */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { - Node, - NodeViewContent, - ReactNodeViewContentProvider, - ReactNodeViewRenderer, -} from '@tiptap/react' +import { Node, NodeViewContent, ReactNodeViewContentProvider, ReactNodeViewRenderer } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' @@ -18,7 +13,7 @@ function MyCustomComponentWithoutContent() { const [count, setCount] = React.useState(200) return ( -
setCount(a => a + 1)}> +
setCount(a => a + 1)}> {count} This is a react component!
) @@ -27,7 +22,7 @@ function MyCustomComponentWithoutContent() { // This component does have a NodeViewContent, so it will render it's children's rich text content function MyCustomComponentWithContent() { return ( -
+
Custom component with content in React!
@@ -74,7 +69,11 @@ const Element = renderToReactElement({ // eslint-disable-next-line react-hooks/rules-of-hooks const [count, setCount] = React.useState(100) - return

setCount(100)}>Can you use React hooks? {count}% {children}

+ return ( +

setCount(100)}> + Can you use React hooks? {count}% {children} +

+ ) }, // Node views are not supported in the static renderer, so you need to supply the custom component yourself customNodeExtensionWithContent({ children }) { diff --git a/packages/static-renderer/src/pm/react/react.tsx b/packages/static-renderer/src/pm/react/react.tsx index d97dba94c23..d38a483ff58 100644 --- a/packages/static-renderer/src/pm/react/react.tsx +++ b/packages/static-renderer/src/pm/react/react.tsx @@ -72,27 +72,28 @@ export function domOutputSpecToReactElement( 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), - ) + return child => + React.createElement( + tag, + mapAttrsToHTMLAttributes(undefined, key.toString()), + domOutputSpecToReactElement(attrs as DOMOutputSpecArray, key++)(child), + ) } if (children === 0) { - return child => React.createElement( + 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, key++)(child), + domOutputSpecToReactElement(attrs as DOMOutputSpecArray)(child), + [children].concat(rest).map(outputSpec => domOutputSpecToReactElement(outputSpec, 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())) @@ -101,13 +102,12 @@ export function domOutputSpecToReactElement( 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)), - ) + return child => + React.createElement( + tag, + mapAttrsToHTMLAttributes(attrs, key.toString()), + [children].concat(rest).map(outputSpec => domOutputSpecToReactElement(outputSpec, key++)(child)), + ) } } @@ -132,9 +132,9 @@ export function renderToReactElement({ extensions, options, }: { - content: Node | JSONContent; - extensions: Extensions; - options?: Partial>; + content: Node | JSONContent + extensions: Extensions + options?: Partial> }): React.ReactNode { return renderToElement({ renderer: renderJSONContentToReactElement, diff --git a/tests/cypress/integration/static-renderer/json-string.spec.ts b/tests/cypress/integration/static-renderer/json-string.spec.ts index f4d6e001ea5..134731d326a 100644 --- a/tests/cypress/integration/static-renderer/json-string.spec.ts +++ b/tests/cypress/integration/static-renderer/json-string.spec.ts @@ -13,13 +13,17 @@ 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', - }], - }], + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + }, + ], + }, + ], attrs: {}, } @@ -44,17 +48,23 @@ describe('static render json to string (no prosemirror)', () => { it('supports mapping nodes & marks', () => { const json = { type: 'doc', - content: [{ - type: 'paragraph', - content: [{ - type: 'text', - text: 'Example Text', - marks: [{ - type: 'bold', - attrs: {}, - }], - }], - }], + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + marks: [ + { + type: 'bold', + attrs: {}, + }, + ], + }, + ], + }, + ], attrs: {}, } @@ -83,20 +93,26 @@ describe('static render json to string (no prosemirror)', () => { it('gives access to the original JSON node or mark', () => { const json = { type: 'doc', - content: [{ - type: 'heading', - attrs: { - level: 2, + content: [ + { + type: 'heading', + attrs: { + level: 2, + }, + content: [ + { + type: 'text', + text: 'Example Text', + marks: [ + { + type: 'bold', + attrs: {}, + }, + ], + }, + ], }, - content: [{ - type: 'text', - text: 'Example Text', - marks: [{ - type: 'bold', - attrs: {}, - }], - }], - }], + ], attrs: {}, } @@ -112,14 +128,18 @@ describe('static render json to string (no prosemirror)', () => { attrs: { level: 2, }, - content: [{ - type: 'text', - text: 'Example Text', - marks: [{ - type: 'bold', - attrs: {}, - }], - }], + content: [ + { + type: 'text', + text: 'Example Text', + marks: [ + { + type: 'bold', + attrs: {}, + }, + ], + }, + ], }) return `${serializeChildrenToHTMLString(children)}` }, @@ -127,10 +147,12 @@ describe('static render json to string (no prosemirror)', () => { expect(node).to.deep.eq({ type: 'text', text: 'Example Text', - marks: [{ - type: 'bold', - attrs: {}, - }], + marks: [ + { + type: 'bold', + attrs: {}, + }, + ], }) return (node as unknown as TextType).text }, @@ -154,28 +176,29 @@ 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: {}, - }], - }], - }], + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + marks: [ + { + type: 'bold', + attrs: {}, + }, + ], + }, + ], + }, + ], attrs: {}, } const html = renderToHTMLString({ content: json, - extensions: [ - Document, - Paragraph, - Text, - Bold, - ], + extensions: [Document, Paragraph, Text, Bold], }) expect(html).to.eq('

Example Text

') @@ -184,28 +207,29 @@ describe('static render json to string (with prosemirror)', () => { it('supports custom mapping for nodes & marks', () => { const json = { type: 'doc', - content: [{ - type: 'paragraph', - content: [{ - type: 'text', - text: 'Example Text', - marks: [{ - type: 'bold', - attrs: {}, - }], - }], - }], + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + marks: [ + { + type: 'bold', + attrs: {}, + }, + ], + }, + ], + }, + ], attrs: {}, } const html = renderToHTMLString({ content: json, - extensions: [ - Document, - Paragraph, - Text, - Bold, - ], + extensions: [Document, Paragraph, Text, Bold], options: { nodeMapping: { doc: ({ children }) => { @@ -224,31 +248,31 @@ describe('static render json to string (with prosemirror)', () => { }) 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: {}, - }], - }], - }], + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example Text', + marks: [ + { + type: 'bold', + attrs: {}, + }, + ], + }, + ], + }, + ], attrs: {}, } const html = renderToHTMLString({ content: json, - extensions: [ - Document, - Paragraph, - Text, - Bold, - ], + extensions: [Document, Paragraph, Text, Bold], options: { nodeMapping: { doc: ({ children, node }) => { diff --git a/tests/cypress/tsconfig.json b/tests/cypress/tsconfig.json index c97d82e1176..5ae499986f0 100644 --- a/tests/cypress/tsconfig.json +++ b/tests/cypress/tsconfig.json @@ -10,7 +10,7 @@ "@tiptap/*": ["packages/*/src", "packages/*/dist"], "@tiptap/pm/*": ["packages/pm/*"], "@tiptap/static-renderer/pm": ["../../static-renderer/dist/pm/*"], - "@tiptap/static-renderer/json": ["../../static-renderer/dist/json/*"], + "@tiptap/static-renderer/json": ["../../static-renderer/dist/json/*"] }, "typeRoots": ["../../node_modules/@types", "../../node_modules/"] }, From 5e41d0a3dbeee557f5097664972e6d230e1257b0 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 6 Jan 2025 12:59:19 +0100 Subject: [PATCH 27/31] chore: put back file --- packages/extension-font-size/tsup.config.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 packages/extension-font-size/tsup.config.ts diff --git a/packages/extension-font-size/tsup.config.ts b/packages/extension-font-size/tsup.config.ts new file mode 100644 index 00000000000..03b7c8d0b6d --- /dev/null +++ b/packages/extension-font-size/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + tsconfig: '../../tsconfig.build.json', + outDir: 'dist', + dts: true, + clean: true, + sourcemap: true, + format: ['esm', 'cjs'], +}) From c0824a45311f219fe58e3f7ba5197990eb63755f Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 6 Jan 2025 13:04:35 +0100 Subject: [PATCH 28/31] fix: export markdown --- .../Examples/StaticRendering/React/index.tsx | 33 +++++++++++++++++-- packages/static-renderer/src/index.ts | 1 + 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/demos/src/Examples/StaticRendering/React/index.tsx b/demos/src/Examples/StaticRendering/React/index.tsx index baf16598b0e..53e3f975b10 100644 --- a/demos/src/Examples/StaticRendering/React/index.tsx +++ b/demos/src/Examples/StaticRendering/React/index.tsx @@ -5,7 +5,7 @@ import ListItem from '@tiptap/extension-list-item' import TextStyle from '@tiptap/extension-text-style' import { EditorProvider, JSONContent, useCurrentEditor, useEditorState } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' -import { renderToHTMLString, renderToReactElement } from '@tiptap/static-renderer' +import { renderToHTMLString, renderToMarkdown, renderToReactElement } from '@tiptap/static-renderer' import React, { useState } from 'react' const extensions = [StarterKit, Color.configure({ types: [TextStyle.name, ListItem.name] }), TextStyle] @@ -42,7 +42,7 @@ const content = ` ` export default () => { - const [tab, setTab] = useState<'react' | 'html' | 'html-element'>('react') + const [tab, setTab] = useState<'react' | 'html' | 'html-element' | 'markdown'>('react') const [currentJSON, setJSON] = useState(null) return (
@@ -92,6 +92,17 @@ export default () => { /> HTML Element +
{tab === 'react' && ( @@ -163,6 +174,24 @@ export default () => { >
)} + {tab === 'markdown' && ( +
+

Markdown

+

+ This example renders the JSON content into a markdown without using an editor instance, document parser or + markdown library. +

+
+            
+              {currentJSON &&
+                renderToMarkdown({
+                  content: currentJSON,
+                  extensions,
+                })}
+            
+          
+
+ )} ) } diff --git a/packages/static-renderer/src/index.ts b/packages/static-renderer/src/index.ts index 5e1d2cfb937..6d620135e10 100644 --- a/packages/static-renderer/src/index.ts +++ b/packages/static-renderer/src/index.ts @@ -2,4 +2,5 @@ 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' From 1249694d1053ed39378e60e2430cf22251aa57fc Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 6 Jan 2025 13:26:24 +0100 Subject: [PATCH 29/31] test: update path resolution for static-renderer --- tests/cypress/plugins/index.js | 10 ++++++++++ tests/cypress/tsconfig.json | 6 +++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/cypress/plugins/index.js b/tests/cypress/plugins/index.js index 9fdeee1dc0c..58f7c5e11b7 100644 --- a/tests/cypress/plugins/index.js +++ b/tests/cypress/plugins/index.js @@ -30,6 +30,16 @@ module.exports = on => { .forEach(name => { alias[`@tiptap/pm${name.split('/').slice(0, -1).join('/')}$`] = path.resolve(`../packages/pm/${name}/index.ts`) }) + // Specifically resolve the static-renderer package + alias['@tiptap/static-renderer/json/html-string$'] = path.resolve( + '../packages/static-renderer/src/json/html-string/index.ts', + ) + alias['@tiptap/static-renderer/pm/html-string$'] = path.resolve( + '../packages/static-renderer/src/pm/html-string/index.ts', + ) + alias['@tiptap/static-renderer/pm/react$'] = path.resolve('../packages/static-renderer/src/pm/react/index.ts') + alias['@tiptap/static-renderer/pm/markdown$'] = path.resolve('../packages/static-renderer/src/pm/markdown/index.ts') + alias['@tiptap/static-renderer$'] = path.resolve('../packages/static-renderer/src/index.ts') const options = { webpackOptions: { diff --git a/tests/cypress/tsconfig.json b/tests/cypress/tsconfig.json index 5ae499986f0..d50f9e776b2 100644 --- a/tests/cypress/tsconfig.json +++ b/tests/cypress/tsconfig.json @@ -7,10 +7,10 @@ "sourceMap": false, "types": ["cypress", "react", "react-dom"], "paths": { + "@tiptap/static-renderer/pm/*": ["packages/static-renderer/src/pm/*"], + "@tiptap/static-renderer/json/*": ["packages/static-renderer/src/json/*"], "@tiptap/*": ["packages/*/src", "packages/*/dist"], - "@tiptap/pm/*": ["packages/pm/*"], - "@tiptap/static-renderer/pm": ["../../static-renderer/dist/pm/*"], - "@tiptap/static-renderer/json": ["../../static-renderer/dist/json/*"] + "@tiptap/pm/*": ["packages/pm/*"] }, "typeRoots": ["../../node_modules/@types", "../../node_modules/"] }, From 97ad2d1874e66b47e822826ae435112b8fbc57d6 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 6 Jan 2025 13:49:17 +0100 Subject: [PATCH 30/31] docs: add a more complex react example --- .../Examples/StaticRendering/React/index.tsx | 7 + .../StaticRenderingAdvanced/React/index.html | 0 .../React/index.spec.js | 9 + .../StaticRenderingAdvanced/React/index.tsx | 305 ++++++ .../StaticRenderHTML/React/index.tsx | 7 + .../StaticRenderHTML/Vue/index.vue | 7 + .../StaticRenderReact/React/index.tsx | 6 +- .../src/json/html-string/string.example.ts | 47 - .../src/json/react/react.example.ts | 44 - .../src/pm/html-string/html-string.example.ts | 224 ---- .../src/pm/markdown/markdown.example.ts | 983 ------------------ .../src/pm/react/react.example.tsx | 305 ------ 12 files changed, 340 insertions(+), 1604 deletions(-) create mode 100644 demos/src/Examples/StaticRenderingAdvanced/React/index.html create mode 100644 demos/src/Examples/StaticRenderingAdvanced/React/index.spec.js create mode 100644 demos/src/Examples/StaticRenderingAdvanced/React/index.tsx delete mode 100644 packages/static-renderer/src/json/html-string/string.example.ts delete mode 100644 packages/static-renderer/src/json/react/react.example.ts delete mode 100644 packages/static-renderer/src/pm/html-string/html-string.example.ts delete mode 100644 packages/static-renderer/src/pm/markdown/markdown.example.ts delete mode 100644 packages/static-renderer/src/pm/react/react.example.tsx diff --git a/demos/src/Examples/StaticRendering/React/index.tsx b/demos/src/Examples/StaticRendering/React/index.tsx index 53e3f975b10..288efcff6a7 100644 --- a/demos/src/Examples/StaticRendering/React/index.tsx +++ b/demos/src/Examples/StaticRendering/React/index.tsx @@ -41,6 +41,13 @@ const content = ` ` +/** + * This example demonstrates how to render a Prosemirror Node (or JSON Content) to a React Element. + * It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method. + * This can be useful if you want to render content to React without having an actual editor instance. + * + * You have complete control over the rendering process. And can replace how each Node/Mark is rendered. + */ export default () => { const [tab, setTab] = useState<'react' | 'html' | 'html-element' | 'markdown'>('react') const [currentJSON, setJSON] = useState(null) diff --git a/demos/src/Examples/StaticRenderingAdvanced/React/index.html b/demos/src/Examples/StaticRenderingAdvanced/React/index.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/demos/src/Examples/StaticRenderingAdvanced/React/index.spec.js b/demos/src/Examples/StaticRenderingAdvanced/React/index.spec.js new file mode 100644 index 00000000000..bb8d0c1e736 --- /dev/null +++ b/demos/src/Examples/StaticRenderingAdvanced/React/index.spec.js @@ -0,0 +1,9 @@ +context('/src/Examples/StaticRenderingAdvanced/React/', () => { + before(() => { + cy.visit('/src/Examples/StaticRenderingAdvanced/React/') + }) + + it('should render the content as HTML', () => { + cy.get('p').should('exist') + }) +}) diff --git a/demos/src/Examples/StaticRenderingAdvanced/React/index.tsx b/demos/src/Examples/StaticRenderingAdvanced/React/index.tsx new file mode 100644 index 00000000000..3b29da32674 --- /dev/null +++ b/demos/src/Examples/StaticRenderingAdvanced/React/index.tsx @@ -0,0 +1,305 @@ +import { Node, NodeViewContent, ReactNodeViewContentProvider, ReactNodeViewRenderer } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { renderToReactElement } from '@tiptap/static-renderer' +import React, { useMemo } from 'react' + +// This component does not have a NodeViewContent, so it does not render it's children's rich text content +function MyCustomComponentWithoutContent() { + const [count, setCount] = React.useState(200) + + return ( +
+ + {count} Custom component without content in React! +
+ ) +} + +// This component does have a NodeViewContent, so it will render it's children's rich text content +function MyCustomComponentWithContent() { + const [count, setCount] = React.useState(200) + return ( +
+ + {count} Custom component with content in React! + +
+ ) +} + +const CustomNodeExtensionWithContent = Node.create({ + name: 'customNodeExtensionWithContent', + content: 'text*', + group: 'block', + renderHTML() { + return ['div', { class: 'my-custom-component-with-content' }, 0] as const + }, + addNodeView() { + return ReactNodeViewRenderer(MyCustomComponentWithContent) + }, +}) + +const CustomNodeExtensionWithoutContent = Node.create({ + name: 'customNodeExtensionWithoutContent', + atom: true, + renderHTML() { + return ['div', { class: 'my-custom-component-without-content' }] as const + }, + addNodeView() { + return ReactNodeViewRenderer(MyCustomComponentWithoutContent) + }, +}) + +/** + * This example demonstrates how to render a Prosemirror Node (or JSON Content) to a React Element. + * It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method. + * This can be useful if you want to render content to React without having an actual editor instance. + * + * You have complete control over the rendering process. And can replace how each Node/Mark is rendered. + */ +export default () => { + const output = useMemo(() => { + return renderToReactElement({ + extensions: [StarterKit, CustomNodeExtensionWithContent, CustomNodeExtensionWithoutContent], + options: { + nodeMapping: { + // You can replace the rendering of a node with a custom react component + heading({ node, children }) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [show, setEnabled] = React.useState(false) + + return ( +

setEnabled(true)}> + {show ? `100% you can use React hooks!` : `Can you use React hooks? Click to find out!`} {children} +

+ ) + }, + // Node views are not supported in the static renderer, so you need to supply the custom component yourself + customNodeExtensionWithContent({ children }) { + return ( + + + + ) + }, + customNodeExtensionWithoutContent() { + return + }, + }, + markMapping: {}, + }, + content: { + 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,', + }, + ], + }, + // This is a custom node extension with content + { + type: 'customNodeExtensionWithContent', + content: [ + { + type: 'text', + text: 'MY CUSTOM COMPONENT CONTENT!!!', + }, + ], + }, + // This is a custom node extension without content + { + type: 'customNodeExtensionWithoutContent', + }, + { + 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', + }, + ], + }, + ], + }, + ], + }, + }) + }, []) + + return
{output}
+} diff --git a/demos/src/GuideContent/StaticRenderHTML/React/index.tsx b/demos/src/GuideContent/StaticRenderHTML/React/index.tsx index 72d2c1b186d..3cc024e0957 100644 --- a/demos/src/GuideContent/StaticRenderHTML/React/index.tsx +++ b/demos/src/GuideContent/StaticRenderHTML/React/index.tsx @@ -29,6 +29,13 @@ const json = { ], } +/** + * This example demonstrates how to render a Prosemirror Node (or JSON Content) to an HTML string. + * It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method. + * This can be useful if you want to render content to HTML without having an actual editor instance. + * + * You have complete control over the rendering process. And can replace how each Node/Mark is rendered. + */ export default () => { const output = useMemo(() => { return renderToHTMLString({ diff --git a/demos/src/GuideContent/StaticRenderHTML/Vue/index.vue b/demos/src/GuideContent/StaticRenderHTML/Vue/index.vue index a62bab2fb20..0142eb4bbb9 100644 --- a/demos/src/GuideContent/StaticRenderHTML/Vue/index.vue +++ b/demos/src/GuideContent/StaticRenderHTML/Vue/index.vue @@ -33,6 +33,13 @@ const json = { ], } +/** + * This example demonstrates how to render a Prosemirror Node (or JSON Content) to an HTML string. + * It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method. + * This can be useful if you want to render content to HTML without having an actual editor instance. + * + * You have complete control over the rendering process. And can replace how each Node/Mark is rendered. + */ export default { computed: { output() { diff --git a/demos/src/GuideContent/StaticRenderReact/React/index.tsx b/demos/src/GuideContent/StaticRenderReact/React/index.tsx index 8aa8456ee92..7c2ea82cc59 100644 --- a/demos/src/GuideContent/StaticRenderReact/React/index.tsx +++ b/demos/src/GuideContent/StaticRenderReact/React/index.tsx @@ -30,7 +30,11 @@ const json = { } /** - * This will statically render the JSON into React elements, which can be directly rendered in the DOM (even on the server). + * This example demonstrates how to render a Prosemirror Node (or JSON Content) to a React Element. + * It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method. + * This can be useful if you want to render content to React without having an actual editor instance. + * + * You have complete control over the rendering process. And can replace how each Node/Mark is rendered. */ export default () => { const output = useMemo(() => { diff --git a/packages/static-renderer/src/json/html-string/string.example.ts b/packages/static-renderer/src/json/html-string/string.example.ts deleted file mode 100644 index 60ebb90cea4..00000000000 --- a/packages/static-renderer/src/json/html-string/string.example.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { TextType } from '@tiptap/core' - -import { renderJSONContentToString } from './string.js' - -/** - * This example demonstrates how to render a JSON representation of a node to a string - * It does so without including Prosemirror or Tiptap, it is the lightest possible way to render JSON content - * But, since it doesn't include Prosemirror or Tiptap, it cannot automatically render marks or nodes for you. - * If you need that, you should use the `renderToHTMLString` from `@tiptap/static-renderer` - * - * You have complete control over the rendering process. And can replace how each Node/Mark is rendered. - */ - -// eslint-disable-next-line no-console -console.log( - renderJSONContentToString({ - nodeMapping: { - text({ node }) { - return (node as unknown as TextType).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: {}, - })({ - content: { - type: 'heading', - content: [ - { - type: 'text', - text: 'hello world', - marks: [], - }, - ], - attrs: { level: 2 }, - }, - }), -) diff --git a/packages/static-renderer/src/json/react/react.example.ts b/packages/static-renderer/src/json/react/react.example.ts deleted file mode 100644 index 7c6fc99b5de..00000000000 --- a/packages/static-renderer/src/json/react/react.example.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { NodeType, TextType } from '@tiptap/core' -import React from 'react' - -import { NodeProps } from '../renderer.js' -import { renderJSONContentToReactElement } from './react.js' - -/** - * This example demonstrates how to render a JSON representation of a node to a React element - * It does so without including Prosemirror or Tiptap, it is the lightest possible way to render JSON content - * But, since it doesn't include Prosemirror or Tiptap, it cannot automatically render marks or nodes for you. - * If you need that, you should use the `renderToReactElement` from `@tiptap/static-renderer` - * - * You have complete control over the rendering process. And can replace how each Node/Mark is rendered. - */ - -// eslint-disable-next-line no-console -console.log( - renderJSONContentToReactElement({ - nodeMapping: { - text({ node }) { - return (node as unknown as TextType).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: {}, - })({ - content: { - type: 'heading', - content: [ - { - type: 'text', - text: 'hello world', - marks: [], - }, - ], - attrs: { level: 2 }, - }, - }), -) diff --git a/packages/static-renderer/src/pm/html-string/html-string.example.ts b/packages/static-renderer/src/pm/html-string/html-string.example.ts deleted file mode 100644 index 6079b829731..00000000000 --- a/packages/static-renderer/src/pm/html-string/html-string.example.ts +++ /dev/null @@ -1,224 +0,0 @@ -import StarterKit from '@tiptap/starter-kit' - -import { renderToHTMLString, serializeAttrsToHTMLString, serializeChildrenToHTMLString } from './html-string.js' - -/** - * This example demonstrates how to render a Prosemirror Node (or JSON Content) to an HTML string. - * It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method. - * This can be useful if you want to render content to HTML without having an actual editor instance. - * - * You have complete control over the rendering process. And can replace how each Node/Mark is rendered. - */ - -// eslint-disable-next-line no-console -console.log( - renderToHTMLString({ - extensions: [StarterKit], - options: { - nodeMapping: { - heading({ node, children }) { - const level = node.attrs.level - - return `THIS IS AN EXAMPLE OF CUSTOM HTML STRING RENDERING${serializeChildrenToHTMLString(children)}` - }, - }, - markMapping: {}, - }, - content: { - 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/markdown/markdown.example.ts b/packages/static-renderer/src/pm/markdown/markdown.example.ts deleted file mode 100644 index 8a14c91a80d..00000000000 --- a/packages/static-renderer/src/pm/markdown/markdown.example.ts +++ /dev/null @@ -1,983 +0,0 @@ -import { Highlight } from '@tiptap/extension-highlight' -import { Subscript } from '@tiptap/extension-subscript' -import { Superscript } from '@tiptap/extension-superscript' -import { Table } from '@tiptap/extension-table' -import { TableCell } from '@tiptap/extension-table-cell' -import { TableHeader } from '@tiptap/extension-table-header' -import { TableRow } from '@tiptap/extension-table-row' -import { TextAlign } from '@tiptap/extension-text-align' -import { TextStyle } from '@tiptap/extension-text-style' -import StarterKit from '@tiptap/starter-kit' - -import { renderToMarkdown } from './markdown.js' - -// eslint-disable-next-line no-console -console.log( - renderToMarkdown({ - extensions: [ - StarterKit, - Subscript, - Superscript, - TextAlign, - TextStyle, - Highlight, - Table, - TableRow, - TableCell, - TableHeader, - ], - content: { - type: 'doc', - from: 0, - to: 747, - content: [ - { - type: 'paragraph', - from: 0, - to: 66, - content: [ - { - type: 'text', - from: 1, - to: 65, - text: 'Markdown shortcuts make it easy to format the text while typing.', - }, - ], - }, - { - type: 'paragraph', - from: 66, - to: 205, - content: [ - { - type: 'text', - from: 67, - to: 107, - text: 'To test that, start a new line and type ', - }, - { - type: 'text', - from: 107, - to: 108, - marks: [ - { - type: 'code', - }, - ], - text: '#', - }, - { - type: 'text', - from: 108, - to: 151, - text: ' followed by a space to get a heading. Try ', - }, - { - type: 'text', - from: 151, - to: 152, - marks: [ - { - type: 'code', - }, - ], - text: '#', - }, - { - type: 'text', - from: 152, - to: 154, - text: ', ', - }, - { - type: 'text', - from: 154, - to: 156, - marks: [ - { - type: 'code', - }, - ], - text: '##', - }, - { - type: 'text', - from: 156, - to: 158, - text: ', ', - }, - { - type: 'text', - from: 158, - to: 161, - marks: [ - { - type: 'code', - }, - ], - text: '###', - }, - { - type: 'text', - from: 161, - to: 163, - text: ', ', - }, - { - type: 'text', - from: 163, - to: 167, - marks: [ - { - type: 'code', - }, - ], - text: '####', - }, - { - type: 'text', - from: 167, - to: 169, - text: ', ', - }, - { - type: 'text', - from: 169, - to: 174, - marks: [ - { - type: 'code', - }, - ], - text: '#####', - }, - { - type: 'text', - from: 174, - to: 176, - text: ', ', - }, - { - type: 'text', - from: 176, - to: 182, - marks: [ - { - type: 'code', - }, - ], - text: '######', - }, - { - type: 'text', - from: 182, - to: 204, - text: ' for different levels.', - }, - ], - }, - { - type: 'paragraph', - from: 205, - to: 442, - content: [ - { - type: 'text', - from: 206, - to: 299, - text: 'Those conventions are called input rules in Tiptap. Some of them are enabled by default. Try ', - }, - { - type: 'text', - from: 299, - to: 300, - marks: [ - { - type: 'code', - }, - ], - text: '>', - }, - { - type: 'text', - from: 300, - to: 318, - text: ' for blockquotes, ', - }, - { - type: 'text', - from: 318, - to: 319, - marks: [ - { - type: 'code', - }, - ], - text: '*', - }, - { - type: 'text', - from: 319, - to: 321, - text: ', ', - }, - { - type: 'text', - from: 321, - to: 322, - marks: [ - { - type: 'code', - }, - ], - text: '-', - }, - { - type: 'text', - from: 322, - to: 326, - text: ' or ', - }, - { - type: 'text', - from: 326, - to: 327, - marks: [ - { - type: 'code', - }, - ], - text: '+', - }, - { - type: 'text', - from: 327, - to: 349, - text: ' for bullet lists, or ', - }, - { - type: 'text', - from: 349, - to: 357, - marks: [ - { - type: 'code', - }, - ], - text: '`foobar`', - }, - { - type: 'text', - from: 357, - to: 377, - text: ' to highlight code, ', - }, - { - type: 'text', - from: 377, - to: 387, - marks: [ - { - type: 'code', - }, - ], - text: '~~tildes~~', - }, - { - type: 'text', - from: 387, - to: 407, - text: ' to strike text, or ', - }, - { - type: 'text', - from: 407, - to: 422, - marks: [ - { - type: 'code', - }, - ], - text: '==equal signs==', - }, - { - type: 'text', - from: 422, - to: 441, - text: ' to highlight text.', - }, - ], - }, - { - type: 'paragraph', - from: 442, - to: 450, - content: [ - { - type: 'text', - from: 443, - to: 447, - marks: [ - { - type: 'highlight', - }, - ], - text: 'TEST', - }, - { - type: 'text', - from: 447, - to: 449, - text: ' f', - }, - ], - }, - { - type: 'blockquote', - from: 450, - to: 459, - content: [ - { - type: 'paragraph', - from: 451, - to: 458, - content: [ - { - type: 'text', - from: 452, - to: 457, - text: 'fodks', - }, - ], - }, - ], - }, - { - type: 'paragraph', - from: 459, - to: 463, - content: [ - { - type: 'text', - from: 460, - to: 462, - marks: [ - { - type: 'code', - }, - ], - text: 'ok', - }, - ], - }, - { - type: 'bulletList', - from: 463, - to: 475, - content: [ - { - type: 'listItem', - from: 464, - to: 474, - content: [ - { - type: 'paragraph', - from: 465, - to: 473, - content: [ - { - type: 'text', - from: 466, - to: 472, - text: 'bullet', - }, - ], - }, - ], - }, - ], - }, - { - type: 'orderedList', - from: 475, - to: 486, - attrs: { - start: 1, - }, - content: [ - { - type: 'listItem', - from: 476, - to: 485, - content: [ - { - type: 'paragraph', - from: 477, - to: 484, - content: [ - { - type: 'text', - from: 478, - to: 483, - text: 'order', - }, - ], - }, - ], - }, - ], - }, - { - type: 'paragraph', - from: 486, - to: 492, - content: [ - { - type: 'text', - from: 487, - to: 491, - marks: [ - { - type: 'strike', - }, - ], - text: 'test', - }, - ], - }, - { - type: 'heading', - from: 492, - to: 496, - attrs: { - level: 1, - }, - content: [ - { - type: 'text', - from: 493, - to: 495, - text: 'h1', - }, - ], - }, - { - type: 'paragraph', - from: 496, - to: 584, - content: [ - { - type: 'text', - from: 497, - to: 583, - text: 'You can overwrite existing input rules or add your own to nodes, marks and extensions.', - }, - ], - }, - { - type: 'paragraph', - from: 584, - to: 745, - content: [ - { - type: 'text', - from: 585, - to: 611, - text: 'For example, we added the ', - }, - { - type: 'text', - from: 611, - to: 621, - marks: [ - { - type: 'code', - }, - ], - text: 'Typography', - }, - { - type: 'text', - from: 621, - to: 649, - text: ' extension here. Try typing ', - }, - { - type: 'text', - from: 649, - to: 652, - marks: [ - { - type: 'code', - }, - ], - text: '(c)', - }, - { - type: 'text', - from: 652, - to: 721, - text: ' to see how it’s converted to a proper © character. You can also try ', - }, - { - type: 'text', - from: 721, - to: 723, - marks: [ - { - type: 'code', - }, - ], - text: '->', - }, - { - type: 'text', - from: 723, - to: 725, - text: ', ', - }, - { - type: 'text', - from: 725, - to: 727, - marks: [ - { - type: 'code', - }, - ], - text: '>>', - }, - { - type: 'text', - from: 727, - to: 729, - text: ', ', - }, - { - type: 'text', - from: 729, - to: 732, - marks: [ - { - type: 'code', - }, - ], - text: '1/2', - }, - { - type: 'text', - from: 732, - to: 734, - text: ', ', - }, - { - type: 'text', - from: 734, - to: 736, - marks: [ - { - type: 'code', - }, - ], - text: '!=', - }, - { - type: 'text', - from: 736, - to: 741, - text: ', or ', - }, - { - type: 'text', - from: 741, - to: 743, - marks: [ - { - type: 'code', - }, - ], - text: '--', - }, - { - type: 'text', - from: 743, - to: 744, - text: '.', - }, - ], - }, - { - type: 'table', - from: 195, - to: 380, - content: [ - { - type: 'tableRow', - from: 196, - to: 221, - content: [ - { - type: 'tableHeader', - from: 197, - to: 205, - attrs: { - colspan: 1, - rowspan: 1, - colwidth: [200], - }, - content: [ - { - type: 'paragraph', - from: 198, - to: 204, - content: [ - { - type: 'text', - from: 199, - to: 203, - text: 'Name', - }, - ], - }, - ], - }, - { - type: 'tableHeader', - from: 205, - to: 220, - attrs: { - colspan: 3, - rowspan: 1, - colwidth: [150, 100], - }, - content: [ - { - type: 'paragraph', - from: 206, - to: 219, - content: [ - { - type: 'text', - from: 207, - to: 218, - text: 'Description', - }, - ], - }, - ], - }, - ], - }, - { - type: 'tableRow', - from: 221, - to: 274, - content: [ - { - type: 'tableCell', - from: 222, - to: 238, - attrs: { - colspan: 1, - rowspan: 1, - colwidth: null, - backgroundColor: null, - }, - content: [ - { - type: 'paragraph', - from: 223, - to: 237, - content: [ - { - type: 'text', - from: 224, - to: 236, - text: 'Cyndi Lauper', - }, - ], - }, - ], - }, - { - type: 'tableCell', - from: 238, - to: 248, - attrs: { - colspan: 1, - rowspan: 1, - colwidth: null, - backgroundColor: null, - }, - content: [ - { - type: 'paragraph', - from: 239, - to: 247, - content: [ - { - type: 'text', - from: 240, - to: 246, - text: 'Singer', - }, - ], - }, - ], - }, - { - type: 'tableCell', - from: 248, - to: 262, - attrs: { - colspan: 1, - rowspan: 1, - colwidth: null, - backgroundColor: null, - }, - content: [ - { - type: 'paragraph', - from: 249, - to: 261, - content: [ - { - type: 'text', - from: 250, - to: 260, - text: 'Songwriter', - }, - ], - }, - ], - }, - { - type: 'tableCell', - from: 262, - to: 273, - attrs: { - colspan: 1, - rowspan: 1, - colwidth: null, - backgroundColor: null, - }, - content: [ - { - type: 'paragraph', - from: 263, - to: 272, - content: [ - { - type: 'text', - from: 264, - to: 271, - text: 'Actress', - }, - ], - }, - ], - }, - ], - }, - { - type: 'tableRow', - from: 274, - to: 328, - content: [ - { - type: 'tableCell', - from: 275, - to: 290, - attrs: { - colspan: 1, - rowspan: 1, - colwidth: null, - backgroundColor: null, - }, - content: [ - { - type: 'paragraph', - from: 276, - to: 289, - content: [ - { - type: 'text', - from: 277, - to: 288, - text: 'Marie Curie', - }, - ], - }, - ], - }, - { - type: 'tableCell', - from: 290, - to: 303, - attrs: { - colspan: 1, - rowspan: 1, - colwidth: null, - backgroundColor: null, - }, - content: [ - { - type: 'paragraph', - from: 291, - to: 302, - content: [ - { - type: 'text', - from: 292, - to: 301, - text: 'Scientist', - }, - ], - }, - ], - }, - { - type: 'tableCell', - from: 303, - to: 314, - attrs: { - colspan: 1, - rowspan: 1, - colwidth: null, - backgroundColor: null, - }, - content: [ - { - type: 'paragraph', - from: 304, - to: 313, - content: [ - { - type: 'text', - from: 305, - to: 312, - text: 'Chemist', - }, - ], - }, - ], - }, - { - type: 'tableCell', - from: 314, - to: 327, - attrs: { - colspan: 1, - rowspan: 1, - colwidth: null, - backgroundColor: null, - }, - content: [ - { - type: 'paragraph', - from: 315, - to: 326, - content: [ - { - type: 'text', - from: 316, - to: 325, - text: 'Physicist', - }, - ], - }, - ], - }, - ], - }, - { - type: 'tableRow', - from: 328, - to: 379, - content: [ - { - type: 'tableCell', - from: 329, - to: 346, - attrs: { - colspan: 1, - rowspan: 1, - colwidth: null, - backgroundColor: null, - }, - content: [ - { - type: 'paragraph', - from: 330, - to: 345, - content: [ - { - type: 'text', - from: 331, - to: 344, - text: 'Indira Gandhi', - }, - ], - }, - ], - }, - { - type: 'tableCell', - from: 346, - to: 364, - attrs: { - colspan: 1, - rowspan: 1, - colwidth: null, - backgroundColor: null, - }, - content: [ - { - type: 'paragraph', - from: 347, - to: 363, - content: [ - { - type: 'text', - from: 348, - to: 362, - text: 'Prime minister', - }, - ], - }, - ], - }, - { - type: 'tableCell', - from: 364, - to: 378, - attrs: { - colspan: 2, - rowspan: 1, - colwidth: null, - backgroundColor: null, - }, - content: [ - { - type: 'paragraph', - from: 365, - to: 377, - content: [ - { - type: 'text', - from: 366, - to: 376, - text: 'Politician', - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - }), -) diff --git a/packages/static-renderer/src/pm/react/react.example.tsx b/packages/static-renderer/src/pm/react/react.example.tsx deleted file mode 100644 index caa13aa2b41..00000000000 --- a/packages/static-renderer/src/pm/react/react.example.tsx +++ /dev/null @@ -1,305 +0,0 @@ -/* eslint-disable no-plusplus */ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import { Node, NodeViewContent, ReactNodeViewContentProvider, ReactNodeViewRenderer } from '@tiptap/react' -import StarterKit from '@tiptap/starter-kit' -import React from 'react' -import { renderToStaticMarkup } from 'react-dom/server' - -import { renderToReactElement } from './react.jsx' - -// This component does not have a NodeViewContent, so it does not render it's children's rich text content -function MyCustomComponentWithoutContent() { - const [count, setCount] = React.useState(200) - - return ( -
setCount(a => a + 1)}> - {count} This is a react component! -
- ) -} - -// This component does have a NodeViewContent, so it will render it's children's rich text content -function MyCustomComponentWithContent() { - return ( -
- Custom component with content in React! - -
- ) -} - -/** - * This example demonstrates how to render a Prosemirror Node (or JSON Content) to a React Element. - * It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method. - * This can be useful if you want to render content to React without having an actual editor instance. - * - * You have complete control over the rendering process. And can replace how each Node/Mark is rendered. - */ - -const CustomNodeExtensionWithContent = Node.create({ - name: 'customNodeExtensionWithContent', - content: 'text*', - group: 'block', - renderHTML() { - return ['div', { class: 'my-custom-component-with-content' }, 0] as const - }, - addNodeView() { - return ReactNodeViewRenderer(MyCustomComponentWithContent) - }, -}) - -const CustomNodeExtensionWithoutContent = Node.create({ - name: 'customNodeExtensionWithoutContent', - atom: true, - renderHTML() { - return ['div', { class: 'my-custom-component-without-content' }] as const - }, - addNodeView() { - return ReactNodeViewRenderer(MyCustomComponentWithoutContent) - }, -}) - -const Element = renderToReactElement({ - extensions: [StarterKit, CustomNodeExtensionWithContent, CustomNodeExtensionWithoutContent], - options: { - nodeMapping: { - // You can replace the rendering of a node with a custom react component - heading({ node, children }) { - // eslint-disable-next-line react-hooks/rules-of-hooks - const [count, setCount] = React.useState(100) - - return ( -

setCount(100)}> - Can you use React hooks? {count}% {children} -

- ) - }, - // Node views are not supported in the static renderer, so you need to supply the custom component yourself - customNodeExtensionWithContent({ children }) { - return ( - - - - ) - }, - customNodeExtensionWithoutContent() { - return - }, - }, - markMapping: {}, - }, - content: { - 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,', - }, - ], - }, - // This is a custom node extension with content - { - type: 'customNodeExtensionWithContent', - content: [ - { - type: 'text', - text: 'MY CUSTOM COMPONENT CONTENT!!!', - }, - ], - }, - // This is a custom node extension without content - { - type: 'customNodeExtensionWithoutContent', - }, - { - 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', - }, - ], - }, - ], - }, - ], - }, -}) - -// eslint-disable-next-line no-console -console.log(renderToStaticMarkup(Element)) From 639e0d79c56ef5a97c344e52f1e1b5886c6f4f90 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 6 Jan 2025 14:00:01 +0100 Subject: [PATCH 31/31] chore: add changeset of static-renderer docs --- .changeset/blue-shrimps-rush.md | 273 ++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 .changeset/blue-shrimps-rush.md diff --git a/.changeset/blue-shrimps-rush.md b/.changeset/blue-shrimps-rush.md new file mode 100644 index 00000000000..ad7eb3ac6c7 --- /dev/null +++ b/.changeset/blue-shrimps-rush.md @@ -0,0 +1,273 @@ +--- +'@tiptap/static-renderer': major +--- + +# @tiptap/static-renderer + +The `@tiptap/static-renderer` package provides a way to render a Tiptap/ProseMirror document to any target format, like an HTML string, a React component, or even markdown. It does so, by taking the original JSON of a document (or document partial) and attempts to map this to the output format, by matching against a list of nodes & marks. + +## Why Static Render? + +The main use case for static rendering is to render a Tiptap/ProseMirror document on the server-side, for example in a Next.js or Nuxt.js application. This way, you can render the content of your editor to HTML before sending it to the client, which can improve the performance of your application. + +Another use case is to render the content of your editor to another format like markdown, which can be useful if you want to send it to a markdown-based API. + +But what makes it static? The static renderer doesn't require a browser or a DOM to render the content. It's a pure JavaScript function that takes a document (as JSON or Prosemirror Node instance) and returns the target format back. + +## Example + +Render a Tiptap document to an HTML string: + +```js +import StarterKit from '@tiptap/starter-kit' +import { renderToHTMLString } from '@tiptap/static-renderer' + +renderToHTMLString({ + extensions: [StarterKit], // using your extensions + // we can map nodes and marks to HTML elements + options: { + nodeMapping: { + // custom node mappings + }, + markMapping: { + // custom mark mappings + }, + unhandledNode: ({ node }) => { + // handle unhandled nodes + return `[unknown node ${node.type.name}]` + }, + unhandledMark: ({ mark }) => { + // handle unhandled marks + return `[unknown node ${mark.type.name}]` + }, + }, + // the source content to render + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Hello World!', + }, + ], + }, + ], + }, +}) +// returns: '

Hello World!

' +``` + +Render to a React component: + +```js +import StarterKit from '@tiptap/starter-kit' +import { renderToReactElement } from '@tiptap/static-renderer' + +renderToReactElement({ + extensions: [StarterKit], // using your extensions + // we can map nodes and marks to HTML elements + options: { + nodeMapping: { + // custom node mappings + }, + markMapping: { + // custom mark mappings + }, + unhandledNode: ({ node }) => { + // handle unhandled nodes + return `[unknown node ${node.type.name}]` + }, + unhandledMark: ({ mark }) => { + // handle unhandled marks + return `[unknown node ${mark.type.name}]` + }, + }, + // the source content to render + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Hello World!', + }, + ], + }, + ], + }, +}) +// returns a react node that, when evaluated, would be equivalent to: '

Hello World!

' +``` + +There are a number of options available to customize the output, like custom node and mark mappings, or handling unhandled nodes and marks. + +## API + +### `renderToHTMLString` + +```ts +function renderToHTMLString(options: { + extensions: Extension[], + content: ProsemirrorNode | JSONContent, + options?: TiptapHTMLStaticRendererOptions, +}): string +``` + +#### `renderToHTMLString` Options + +- `extensions`: An array of Tiptap extensions that are used to render the content. +- `content`: The content to render. Can be a Prosemirror Node instance or a JSON representation of a Prosemirror document. +- `options`: An object with additional options. +- `options.nodeMapping`: An object that maps Prosemirror nodes to HTML strings. +- `options.markMapping`: An object that maps Prosemirror marks to HTML strings. +- `options.unhandledNode`: A function that is called when an unhandled node is encountered. +- `options.unhandledMark`: A function that is called when an unhandled mark is encountered. + +### `renderToReactElement` + +```ts +function renderToReactElement(options: { + extensions: Extension[], + content: ProsemirrorNode | JSONContent, + options?: TiptapReactStaticRendererOptions, +}): ReactElement +``` + +#### `renderToReactElement` Options + +- `extensions`: An array of Tiptap extensions that are used to render the content. +- `content`: The content to render. Can be a Prosemirror Node instance or a JSON representation of a Prosemirror document. +- `options`: An object with additional options. +- `options.nodeMapping`: An object that maps Prosemirror nodes to React components. +- `options.markMapping`: An object that maps Prosemirror marks to React components. +- `options.unhandledNode`: A function that is called when an unhandled node is encountered. +- `options.unhandledMark`: A function that is called when an unhandled mark is encountered. + +## How does it work? + +Each Tiptap node/mark extension can define a `renderHTML` method which is used to generate default mappings of Prosemirror nodes/marks to the target format. These can be overridden by providing custom mappings in the options. One thing to note is that the static renderer doesn't support node views automatically, so you need to provide a mapping for each node type that you want rendered as a node view. Here is an example of how you can render a node view as a React component: + +```js +import { Node } from '@tiptap/core' +import { ReactNodeViewRenderer } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { renderToReactElement } from '@tiptap/static-renderer' + +// This component does not have a NodeViewContent, so it does not render it's children's rich text content +function MyCustomComponentWithoutContent() { + const [count, setCount] = React.useState(200) + + return ( +
setCount(a => a + 1)}> + {count} This is a react component! +
+ ) +} + +const CustomNodeExtensionWithoutContent = Node.create({ + name: 'customNodeExtensionWithoutContent', + atom: true, + renderHTML() { + return ['div', { class: 'my-custom-component-without-content' }] as const + }, + addNodeView() { + return ReactNodeViewRenderer(MyCustomComponentWithoutContent) + }, +}) + +renderToReactElement({ + extensions: [StarterKit, CustomNodeExtensionWithoutContent], + options: { + nodeMapping: { + // render the custom node with the intended node view React component + customNodeExtensionWithoutContent: MyCustomComponentWithoutContent, + }, + }, + content: { + type: 'doc', + content: [ + { + type: 'customNodeExtensionWithoutContent', + }, + ], + }, +}) +// returns:
200 This is a react component!
+``` + +But what if you want to render the rich text content of the node view? You can do that by providing a `NodeViewContent` component as a child of the node view component: + +```js +import { Node } from '@tiptap/core' +import { + NodeViewContent, + ReactNodeViewContentProvider, + ReactNodeViewRenderer +} from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { renderToReactElement } from '@tiptap/static-renderer' + + +// This component does have a NodeViewContent, so it will render it's children's rich text content +function MyCustomComponentWithContent() { + return ( +
+ Custom component with content in React! + +
+ ) +} + + +const CustomNodeExtensionWithContent = Node.create({ + name: 'customNodeExtensionWithContent', + content: 'text*', + group: 'block', + renderHTML() { + return ['div', { class: 'my-custom-component-with-content' }, 0] as const + }, + addNodeView() { + return ReactNodeViewRenderer(MyCustomComponentWithContent) + }, +}) + + +renderToReactElement({ + extensions: [StarterKit, CustomNodeExtensionWithContent], + options: { + nodeMapping: { + customNodeExtensionWithContent: ({ children }) => { + // To pass the content down into the NodeViewContent component, we need to wrap the custom component with the ReactNodeViewContentProvider + return ( + + + + ) + }, + }, + }, + content: { + type: 'doc', + content: [ + { + type: 'customNodeExtensionWithContent', + // rich text content + content: [ + { + type: 'text', + text: 'Hello, world!', + }, + ], + }, + ], + }, +}) + +// returns:
Custom component with content in React!
Hello, world!
+// Note: The NodeViewContent component is rendered as a div with the attribute data-node-view-content, and the rich text content is rendered inside of it +```