Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(static-renderer): add @tiptap/static-renderer to enable static rendering of content #5528

Open
wants to merge 21 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
91af037
feat: allowing specifying the content of ReacNodeViewContent via a Re…
nperez0111 Aug 20, 2024
0e11ced
refactor: export `resolveExtensions` function
nperez0111 Aug 23, 2024
3c9a9e5
feat: first version of a static renderer
nperez0111 Aug 23, 2024
3958f52
feat(static-renderer): firm up the API and have it render to HTML Str…
nperez0111 Aug 27, 2024
a5ab9da
chore: minor change
nperez0111 Dec 31, 2024
dbf3682
docs: more examples
nperez0111 Aug 27, 2024
2507e8b
fix: better handling of unhandledNode & unhandledMark
nperez0111 Aug 27, 2024
fcad731
chore: add export
nperez0111 Sep 25, 2024
580db92
feat: add support for providing the current node and parent node to m…
nperez0111 Nov 21, 2024
26da70c
build: setup package to be built by tsup instead
nperez0111 Dec 4, 2024
b35e8d2
build: fix build
nperez0111 Dec 31, 2024
f83b060
chore: make package public
nperez0111 Dec 4, 2024
2719989
feat(static-renderer): add markdown output support
nperez0111 Dec 31, 2024
10b9ffb
fix: access nodeOrMark's type safely across JSON & Prosemirror objects
nperez0111 Dec 18, 2024
d66bab4
chore: widen the type to represent the actual representation
nperez0111 Dec 18, 2024
24c1009
feat(static-renderer): add support for namespacing in DOMOutputSpec
nperez0111 Dec 30, 2024
ec95ac0
feat: move types into core package
nperez0111 Dec 31, 2024
df3d708
test: add tests for static renderer
nperez0111 Dec 31, 2024
33813ae
refactor: minor cleanup
nperez0111 Dec 31, 2024
5ad7ca9
test: update tests
nperez0111 Dec 31, 2024
5653a72
chore: build all packages
nperez0111 Dec 31, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions packages/core/src/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -533,7 +535,10 @@ export class Editor extends EventEmitter<EditorEvents> {
/**
* Get the document as JSON.
*/
public getJSON(): JSONContent {
public getJSON(): DocumentType<
Record<string, any> | undefined,
TNodeType<string, undefined | Record<string, any>, any, (TNodeType | TTextType)[]>[]
> {
return this.state.doc.toJSON()
}

Expand Down
102 changes: 17 additions & 85 deletions packages/core/src/ExtensionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<AnyConfig['addExtensions']>(
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<AnyConfig['priority']>(a, 'priority') || defaultPriority
const priorityB = getExtensionField<AnyConfig['priority']>(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.
Expand Down Expand Up @@ -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[] = []
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/helpers/flattenExtensions.ts
Original file line number Diff line number Diff line change
@@ -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<AnyConfig['addExtensions']>(
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)
)
}
4 changes: 2 additions & 2 deletions packages/core/src/helpers/getSchema.ts
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 3 additions & 0 deletions packages/core/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
25 changes: 25 additions & 0 deletions packages/core/src/helpers/resolveExtensions.ts
Original file line number Diff line number Diff line change
@@ -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
}
26 changes: 26 additions & 0 deletions packages/core/src/helpers/sortExtensions.ts
Original file line number Diff line number Diff line change
@@ -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<AnyConfig['priority']>(a, 'priority') || defaultPriority
const priorityB = getExtensionField<AnyConfig['priority']>(b, 'priority') || defaultPriority

if (priorityA > priorityB) {
return -1
}

if (priorityA < priorityB) {
return 1
}

return 0
})
}
55 changes: 55 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = 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<string, any> = 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<string, any> | undefined = Record<string, any>,
TContentType extends NodeType[] = NodeType[],
> = Omit<NodeType<'doc', TDocAttributes, never, TContentType>, '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<TMarkType extends MarkType = MarkType> = {
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, any>]
| [string, 0]
| [string, Record<string, any>, 0]
| [string, Record<string, any>, DOMOutputSpecArray | 0]
| [string, DOMOutputSpecArray];

export type Content = HTMLContent | JSONContent | JSONContent[] | null;

export type CommandProps = {
Expand Down
Loading
Loading