From 1d71714642fded23ec5c0cb6486ae634b1d11115 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 19:29:59 +0000 Subject: [PATCH 1/6] build(deps): bump cypress-io/github-action from 7.1.8 to 7.3.0 Bumps [cypress-io/github-action](https://github.com/cypress-io/github-action) from 7.1.8 to 7.3.0. - [Release notes](https://github.com/cypress-io/github-action/releases) - [Changelog](https://github.com/cypress-io/github-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/cypress-io/github-action/compare/v7.1.8...v7.3.0) --- updated-dependencies: - dependency-name: cypress-io/github-action dependency-version: 7.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .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 9689a49b15..d8edd02ec0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -254,7 +254,7 @@ jobs: run: tar -xzf /tmp/build-output/build-output.tar.gz - name: Test ${{ matrix.test-spec.name }} - uses: cypress-io/github-action@v7.1.8 + uses: cypress-io/github-action@v7.3.0 with: install: false start: pnpm exec http-server ./demos/dist -s -p 3000 From 6a6e8798f1b0b3e7ccdb36dcf3a93f128b69dc8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnau=20G=C3=B3mez=20Farell?= Date: Tue, 12 May 2026 12:11:23 +0200 Subject: [PATCH 2/6] feat: open-source server ai toolkit package --- .changeset/young-mugs-fry.md | 5 + packages/server-ai-toolkit/README.md | 18 ++ packages/server-ai-toolkit/package.json | 58 ++++ packages/server-ai-toolkit/src/README.md | 18 ++ .../server-ai-toolkit-hash-extension.ts | 70 +++++ packages/server-ai-toolkit/src/index.ts | 2 + .../schema-awareness/get-editor-context.ts | 83 ++++++ .../src/schema-awareness/index.ts | 3 + .../src/schema-awareness/legacy-exports.ts | 3 + .../schema-awareness/types/editor-context.ts | 17 ++ .../src/schema-awareness/types/index.ts | 2 + .../src/schema-awareness/types/json-item.ts | 51 ++++ .../utils/default-json-items.ts | 278 ++++++++++++++++++ .../utils/merge-json-items.ts | 14 + .../utils/serialize-schema.ts | 111 +++++++ .../src/server-ai-toolkit-extension.ts | 23 ++ .../src/server-ai-toolkit.spec.ts | 131 +++++++++ packages/server-ai-toolkit/tsup.config.ts | 11 + pnpm-lock.yaml | 26 ++ 19 files changed, 924 insertions(+) create mode 100644 .changeset/young-mugs-fry.md create mode 100644 packages/server-ai-toolkit/README.md create mode 100644 packages/server-ai-toolkit/package.json create mode 100644 packages/server-ai-toolkit/src/README.md create mode 100644 packages/server-ai-toolkit/src/hash-extension/server-ai-toolkit-hash-extension.ts create mode 100644 packages/server-ai-toolkit/src/index.ts create mode 100644 packages/server-ai-toolkit/src/schema-awareness/get-editor-context.ts create mode 100644 packages/server-ai-toolkit/src/schema-awareness/index.ts create mode 100644 packages/server-ai-toolkit/src/schema-awareness/legacy-exports.ts create mode 100644 packages/server-ai-toolkit/src/schema-awareness/types/editor-context.ts create mode 100644 packages/server-ai-toolkit/src/schema-awareness/types/index.ts create mode 100644 packages/server-ai-toolkit/src/schema-awareness/types/json-item.ts create mode 100644 packages/server-ai-toolkit/src/schema-awareness/utils/default-json-items.ts create mode 100644 packages/server-ai-toolkit/src/schema-awareness/utils/merge-json-items.ts create mode 100644 packages/server-ai-toolkit/src/schema-awareness/utils/serialize-schema.ts create mode 100644 packages/server-ai-toolkit/src/server-ai-toolkit-extension.ts create mode 100644 packages/server-ai-toolkit/src/server-ai-toolkit.spec.ts create mode 100644 packages/server-ai-toolkit/tsup.config.ts diff --git a/.changeset/young-mugs-fry.md b/.changeset/young-mugs-fry.md new file mode 100644 index 0000000000..249b25e202 --- /dev/null +++ b/.changeset/young-mugs-fry.md @@ -0,0 +1,5 @@ +--- +'@tiptap/server-ai-toolkit': minor +--- + +Initial open-source release of the Server AI Toolkit package diff --git a/packages/server-ai-toolkit/README.md b/packages/server-ai-toolkit/README.md new file mode 100644 index 0000000000..3f93871e1c --- /dev/null +++ b/packages/server-ai-toolkit/README.md @@ -0,0 +1,18 @@ +# @tiptap/server-ai-toolkit + +[![Version](https://img.shields.io/npm/v/@tiptap/server-ai-toolkit.svg?label=version)](https://www.npmjs.com/package/@tiptap/server-ai-toolkit) +[![Downloads](https://img.shields.io/npm/dm/@tiptap/server-ai-toolkit.svg)](https://npmcharts.com/compare/tiptap?minimal=true) +[![License](https://img.shields.io/npm/l/@tiptap/server-ai-toolkit.svg)](https://www.npmjs.com/package/@tiptap/server-ai-toolkit) +[![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/server-ai-toolkit/package.json b/packages/server-ai-toolkit/package.json new file mode 100644 index 0000000000..e79bc64376 --- /dev/null +++ b/packages/server-ai-toolkit/package.json @@ -0,0 +1,58 @@ +{ + "name": "@tiptap/server-ai-toolkit", + "description": "Integrate AI capabilities into the Tiptap editor. This package contains the SDK for connecting to the Server AI Toolkit service.", + "version": "0.0.0", + "homepage": "https://tiptap.dev/docs/editor/extensions/functionality/server-ai-toolkit", + "keywords": [ + "tiptap", + "tiptap ai", + "ai toolkit", + "server", + "server ai toolkit" + ], + "license": "MIT", + "type": "module", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "exports": { + ".": { + "types": { + "import": "./dist/index.d.ts", + "require": "./dist/index.d.cts" + }, + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "src", + "dist" + ], + "peerDependencies": { + "@tiptap/core": "workspace:*", + "@tiptap/pm": "workspace:*" + }, + "devDependencies": { + "@tiptap/core": "workspace:^", + "@tiptap/pm": "workspace:^" + }, + "dependencies": { + "es-toolkit": "^1.41.0", + "zod": "^4.1.8" + }, + "repository": { + "type": "git", + "url": "https://github.com/ueberdosis/tiptap", + "directory": "packages/server-ai-toolkit" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch --sourcemap", + "lint": "biome check ./src/" + } +} diff --git a/packages/server-ai-toolkit/src/README.md b/packages/server-ai-toolkit/src/README.md new file mode 100644 index 0000000000..7094e58d3e --- /dev/null +++ b/packages/server-ai-toolkit/src/README.md @@ -0,0 +1,18 @@ +# @tiptap/react + +[![Version](https://img.shields.io/npm/v/@tiptap/react.svg?label=version)](https://www.npmjs.com/package/@tiptap/react) +[![Downloads](https://img.shields.io/npm/dm/@tiptap/react.svg)](https://npmcharts.com/compare/tiptap?minimal=true) +[![License](https://img.shields.io/npm/l/@tiptap/react.svg)](https://www.npmjs.com/package/@tiptap/react) +[![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/server-ai-toolkit/src/hash-extension/server-ai-toolkit-hash-extension.ts b/packages/server-ai-toolkit/src/hash-extension/server-ai-toolkit-hash-extension.ts new file mode 100644 index 0000000000..78f3410543 --- /dev/null +++ b/packages/server-ai-toolkit/src/hash-extension/server-ai-toolkit-hash-extension.ts @@ -0,0 +1,70 @@ +import { Extension } from '@tiptap/core' + +/** + * The attribute name used to store the hash on nodes. + */ +const ATTRIBUTE_NAME = '_hash' + +/** + * Checks whether the editor already includes the full {@link AiToolkit} extension. + * + * @param extensionNames - Extension names available in the current editor setup. + * @return `true` when the AI Toolkit extension is installed. + */ +function hasAiToolkitExtension(extensionNames: string[]): boolean { + return extensionNames.includes('aiToolkit') +} + +/** + * A Tiptap extension that registers `_hash` on non-inline nodes. + * + * The server package only contributes the attribute definition when AI Toolkit + * itself is not already installed. + */ +export const ServerAiToolkitHashExtension = Extension.create({ + name: 'serverAiToolkitHash', + + /** + * Registers the hash attribute on all non-inline, non-text, non-doc node types. + * + * @return The global attribute configuration. + */ + addGlobalAttributes() { + if (hasAiToolkitExtension(this.extensions.map(extension => extension.name))) { + return [] + } + + const types = this.extensions + .filter(ext => { + if (ext.name === 'text' || ext.name === 'doc' || ext.name === 'tableHeader' || ext.name === 'tableCell') { + return false + } + if (typeof ext.config?.group === 'string' && ext.config.group.includes('inline')) { + return false + } + return true + }) + .map(ext => ext.name) + + return [ + { + types, + attributes: { + [ATTRIBUTE_NAME]: { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute(ATTRIBUTE_NAME), + renderHTML: (attributes: Record) => { + if (!attributes[ATTRIBUTE_NAME]) { + return {} + } + + return { + [ATTRIBUTE_NAME]: attributes[ATTRIBUTE_NAME], + } + }, + }, + }, + }, + ] + }, +}) diff --git a/packages/server-ai-toolkit/src/index.ts b/packages/server-ai-toolkit/src/index.ts new file mode 100644 index 0000000000..0d5ec20ca4 --- /dev/null +++ b/packages/server-ai-toolkit/src/index.ts @@ -0,0 +1,2 @@ +export * from './schema-awareness/index.js' +export * from './server-ai-toolkit-extension.js' diff --git a/packages/server-ai-toolkit/src/schema-awareness/get-editor-context.ts b/packages/server-ai-toolkit/src/schema-awareness/get-editor-context.ts new file mode 100644 index 0000000000..051d59a41b --- /dev/null +++ b/packages/server-ai-toolkit/src/schema-awareness/get-editor-context.ts @@ -0,0 +1,83 @@ +import { type Editor, getExtensionField, Mark } from '@tiptap/core' +import { z } from 'zod' + +import type { EditorContext } from './types/editor-context.js' +import type { AddJsonSchemaAwareness, JsonItem } from './types/json-item.js' +import { defaultJsonItems } from './utils/default-json-items.js' +import { mergeJsonItems } from './utils/merge-json-items.js' +import { serializeSchema } from './utils/serialize-schema.js' + +/** + * Options for {@link getEditorContext}. + */ +export interface GetEditorContextOptions { + /** + * Custom schema awareness items to include in addition to the default ones. + * + * This lets callers extend the serialized editor context with custom node + * definitions or overrides. + * @default [] + */ + customNodes?: JsonItem[] +} + +/** + * Returns editor context data including a serialized schema and merged custom nodes. + * + * This function collects schema information from extensions and converts it to a format + * suitable for AI model consumption. Zod schemas in attributes are converted to JSON schemas. + * + * @param editor - The Tiptap editor instance + * @param options - Configuration options for the editor context + * @return An object containing the serialized schema and merged custom nodes with JSON schema attributes + */ +export function getEditorContext(editor: Editor, options: GetEditorContextOptions = {}): EditorContext { + const extensionNamesSet = new Set(editor.extensionManager.extensions.map(ext => ext.name)) + + const customNodesFromExtensions = editor.extensionManager.extensions.flatMap(extension => { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + editor, + } + const addJsonSchemaAwareness = getExtensionField( + extension, + 'addJsonSchemaAwareness', + context, + ) + if (addJsonSchemaAwareness) { + return { + ...addJsonSchemaAwareness(), + isMark: extension instanceof Mark, + extensionName: extension.name, + } + } + return [] + }) + + const items = [defaultJsonItems, customNodesFromExtensions, options.customNodes ?? []] + .reduce(mergeJsonItems, []) + .filter((item: JsonItem) => extensionNamesSet.has(item.extensionName)) + + // Convert Zod schemas to JSON schemas + const mergedCustomNodes = items.map((item: JsonItem) => { + const { attributes, ...rest } = item + if (!attributes) { + return rest + } + + const attributesSchema = z.object(attributes) + const jsonSchema = z.toJSONSchema(attributesSchema) + + return { + ...rest, + attributes: jsonSchema, + } + }) + + return { + serializedSchema: serializeSchema(editor.schema), + items: mergedCustomNodes, + } +} diff --git a/packages/server-ai-toolkit/src/schema-awareness/index.ts b/packages/server-ai-toolkit/src/schema-awareness/index.ts new file mode 100644 index 0000000000..bd7a1807f5 --- /dev/null +++ b/packages/server-ai-toolkit/src/schema-awareness/index.ts @@ -0,0 +1,3 @@ +export * from './get-editor-context.js' +export * from './legacy-exports.js' +export * from './types/index.js' diff --git a/packages/server-ai-toolkit/src/schema-awareness/legacy-exports.ts b/packages/server-ai-toolkit/src/schema-awareness/legacy-exports.ts new file mode 100644 index 0000000000..94b01009e1 --- /dev/null +++ b/packages/server-ai-toolkit/src/schema-awareness/legacy-exports.ts @@ -0,0 +1,3 @@ +export type { GetEditorContextOptions as GetSchemaAwarenessDataOptions } from './get-editor-context.js' +export { getEditorContext as getSchemaAwarenessData } from './get-editor-context.js' +export type { EditorContext as SchemaAwarenessData } from './types/editor-context.js' diff --git a/packages/server-ai-toolkit/src/schema-awareness/types/editor-context.ts b/packages/server-ai-toolkit/src/schema-awareness/types/editor-context.ts new file mode 100644 index 0000000000..2ecda5873f --- /dev/null +++ b/packages/server-ai-toolkit/src/schema-awareness/types/editor-context.ts @@ -0,0 +1,17 @@ +import type { SerializedSchema } from '../utils/serialize-schema.js' +import type { SerializedJsonItem } from './json-item.js' + +/** + * Result of {@link getEditorContext} containing information about the + * editor's extensions that the Server AI Toolkit needs to work. + */ +export interface EditorContext { + /** + * The serialized ProseMirror schema + */ + serializedSchema: SerializedSchema + /** + * The merged custom nodes with Zod schemas converted to JSON schemas + */ + items: SerializedJsonItem[] +} diff --git a/packages/server-ai-toolkit/src/schema-awareness/types/index.ts b/packages/server-ai-toolkit/src/schema-awareness/types/index.ts new file mode 100644 index 0000000000..b40b15e3bc --- /dev/null +++ b/packages/server-ai-toolkit/src/schema-awareness/types/index.ts @@ -0,0 +1,2 @@ +export * from './editor-context.js' +export * from './json-item.js' diff --git a/packages/server-ai-toolkit/src/schema-awareness/types/json-item.ts b/packages/server-ai-toolkit/src/schema-awareness/types/json-item.ts new file mode 100644 index 0000000000..cf7354d006 --- /dev/null +++ b/packages/server-ai-toolkit/src/schema-awareness/types/json-item.ts @@ -0,0 +1,51 @@ +import type { Editor } from '@tiptap/core' +import type { z } from 'zod' + +/** + * Represents a JSON schema awareness item for AI model understanding + * + * This interface defines the structure of schema information that is provided + * to AI models to help them understand what JSON elements and attributes are + * available in the editor's schema. + */ +export interface JsonItem { + /** + * The name of the extension that provides this element + */ + extensionName: string + /** + * If `true`, the item is a mark instead of a node. + */ + isMark?: boolean + /** + * The human-readable name of the element in English + */ + name: string + /** + * Explanation of the element in English for the AI model + */ + description?: string | null + /** + * Possible attributes of the JSON element. If `undefined`, there are no attributes. + * The keys are attribute names and the values are Zod schemas that define the attribute structure. + */ + attributes?: Record +} + +export type SerializedJsonItem = Omit & { attributes?: unknown } + +export type AddJsonSchemaAwareness = (this: { + name: string + options: Options + storage: Storage + editor?: Editor +}) => Omit + +declare module '@tiptap/core' { + interface NodeConfig { + addJsonSchemaAwareness?: AddJsonSchemaAwareness + } + interface MarkConfig { + addJsonSchemaAwareness?: AddJsonSchemaAwareness + } +} diff --git a/packages/server-ai-toolkit/src/schema-awareness/utils/default-json-items.ts b/packages/server-ai-toolkit/src/schema-awareness/utils/default-json-items.ts new file mode 100644 index 0000000000..2e4fd6adae --- /dev/null +++ b/packages/server-ai-toolkit/src/schema-awareness/utils/default-json-items.ts @@ -0,0 +1,278 @@ +import { z } from 'zod' + +import type { JsonItem } from '../types/json-item.js' + +export const defaultJsonItems: JsonItem[] = [ + // Core nodes + // Omit the root 'doc' node because the AI does not read it or generate it. + { + extensionName: 'paragraph', + name: 'Paragraph', + }, + { + extensionName: 'text', + name: 'Text', + }, + { + extensionName: 'heading', + name: 'Heading', + description: 'h1 to h6', + attributes: { + level: z.number().min(1).max(6), + }, + }, + { + extensionName: 'blockquote', + name: 'Blockquote', + description: 'for quoted content', + }, + { + extensionName: 'codeBlock', + name: 'Code Block', + attributes: { + language: z.string().nullable().optional(), + }, + }, + { + extensionName: 'hardBreak', + name: 'Line Break', + }, + { + extensionName: 'horizontalRule', + name: 'Horizontal Rule', + description: 'horizontal divider line', + }, + { + extensionName: 'image', + name: 'Image', + attributes: { + src: z.string(), + alt: z.string().nullable().optional(), + title: z.string().nullable().optional(), + width: z.number().nullable().optional(), + height: z.number().nullable().optional(), + }, + }, + // List nodes + { + extensionName: 'bulletList', + name: 'Bullet List', + description: 'Unordered list with bullet points', + }, + { + extensionName: 'orderedList', + name: 'Ordered List', + description: 'Numbered list', + attributes: { + start: z.number().optional(), + type: z + .string() + .nullable() + .optional() + .describe( + 'Numbering style: "1" for numbers, "a" for lowercase letters, "A" for uppercase letters, "i" for lowercase roman numerals, "I" for uppercase roman numerals', + ), + }, + }, + { + extensionName: 'listItem', + name: 'List Item', + description: 'Item within a bulletList or orderedList', + }, + { + extensionName: 'taskList', + name: 'Task List', + description: 'Checklist with task items', + }, + { + extensionName: 'taskItem', + name: 'Task Item', + description: 'Item within a taskList', + attributes: { + checked: z.boolean(), + }, + }, + // Table nodes + { + extensionName: 'table', + name: 'Table', + description: 'Table container node', + }, + { + extensionName: 'tableRow', + name: 'Table Row', + description: 'Row within a table', + }, + { + extensionName: 'tableCell', + name: 'Table Cell', + description: 'Cell within a tableRow', + }, + { + extensionName: 'tableHeader', + name: 'Table Header', + description: 'Header cell within a tableRow', + }, + // Special nodes + { + extensionName: 'mention', + name: 'Mention', + description: 'Mention node for @mentions and similar', + attributes: { + id: z.string(), + label: z.string().nullable().optional(), + mentionSuggestionChar: z + .string() + .optional() + .describe( + 'The character that triggers this mention type (e.g., "@" for users, "#" for tags). Used to distinguish between multiple mention types', + ), + }, + }, + { + extensionName: 'emoji', + name: 'Emoji', + attributes: { + name: z + .string() + .describe('The unique name/shortcode identifier for the emoji (e.g., "smile", "heart", "thumbs_up")'), + }, + }, + { + extensionName: 'youtube', + name: 'YouTube', + attributes: { + src: z.string(), + start: z.number().optional().describe('Start time in seconds for when the video should begin playing'), + width: z.number().optional(), + height: z.number().optional(), + }, + }, + { + extensionName: 'details', + name: 'Details', + description: 'Collapsible details/summary', + attributes: { + open: z.boolean().optional(), + }, + }, + { + extensionName: 'detailsSummary', + name: 'Details Summary', + description: 'Summary text for a details node', + }, + { + extensionName: 'detailsContent', + name: 'Details Content', + description: 'Collapsible content within a details node', + }, + { + extensionName: 'blockMath', + name: 'Block Math', + attributes: { + latex: z.string(), + }, + }, + { + extensionName: 'inlineMath', + name: 'Inline Math', + attributes: { + latex: z.string(), + }, + }, + // Marks + { + extensionName: 'bold', + name: 'Bold', + isMark: true, + }, + { + extensionName: 'italic', + name: 'Italic', + isMark: true, + }, + { + extensionName: 'code', + name: 'Code', + isMark: true, + }, + { + extensionName: 'link', + name: 'Link', + isMark: true, + attributes: { + href: z.string(), + target: z.string().nullable().optional(), + rel: z.string().nullable().optional(), + class: z.string().nullable().optional(), + }, + }, + { + extensionName: 'strike', + name: 'Strike', + isMark: true, + }, + { + extensionName: 'underline', + name: 'Underline', + isMark: true, + }, + { + extensionName: 'highlight', + name: 'Highlight', + isMark: true, + attributes: { + color: z.string().nullable().optional(), + }, + }, + { + extensionName: 'subscript', + name: 'Subscript', + isMark: true, + }, + { + extensionName: 'superscript', + name: 'Superscript', + isMark: true, + }, + { + extensionName: 'textStyle', + name: 'Text Style', + isMark: true, + attributes: { + fontFamily: z.string(), + fontSize: z.string(), + lineHeight: z.string(), + color: z.string(), + backgroundColor: z.string(), + }, + }, + // tiptap-pro nodes + { + extensionName: 'iframely', + name: 'Iframely', + description: 'Embed content from URLs using Iframely service', + attributes: { + url: z.string().nullable(), + data: z.any().nullable().optional(), + }, + }, + { + extensionName: 'blockThread', + name: 'Block Thread', + description: 'Block-level comment thread', + attributes: { + 'data-thread-id': z.string(), + }, + }, + // tiptap-pro marks + { + extensionName: 'inlineThread', + name: 'Inline Thread', + isMark: true, + description: 'Inline comment thread', + attributes: { + 'data-thread-id': z.string(), + }, + }, +] diff --git a/packages/server-ai-toolkit/src/schema-awareness/utils/merge-json-items.ts b/packages/server-ai-toolkit/src/schema-awareness/utils/merge-json-items.ts new file mode 100644 index 0000000000..d26014ff82 --- /dev/null +++ b/packages/server-ai-toolkit/src/schema-awareness/utils/merge-json-items.ts @@ -0,0 +1,14 @@ +import type { JsonItem } from '../types/json-item.js' + +/** + * When new JSON items are added, we need to remove from `items` the items that + * are duplicated in `newItems` + * @param items - The original array of JSON items + * @param newItems - The new array of JSON items to add + * @returns A new array of JSON items where the items from `newItems` are added + * and the items from `items` that are duplicated in `newItems` are removed + */ +export function mergeJsonItems(items: JsonItem[], newItems: JsonItem[]): JsonItem[] { + const newItemsSet = new Set(newItems.map(item => item.extensionName)) + return [...items.filter(item => !newItemsSet.has(item.extensionName)), ...newItems] +} diff --git a/packages/server-ai-toolkit/src/schema-awareness/utils/serialize-schema.ts b/packages/server-ai-toolkit/src/schema-awareness/utils/serialize-schema.ts new file mode 100644 index 0000000000..5cc55dc594 --- /dev/null +++ b/packages/server-ai-toolkit/src/schema-awareness/utils/serialize-schema.ts @@ -0,0 +1,111 @@ +import type { AttributeSpec, MarkSpec, NodeSpec, Schema } from '@tiptap/pm/model' +import { omit } from 'es-toolkit' + +/** + * A serialized version of AttributeSpec where the validate function is converted to a string. + * This allows the attribute specification to be JSON-serializable. + */ +export type SerializedAttributeSpec = Omit & { + validate?: string +} + +/** + * A type representing serialized attributes as a record of attribute names to their serialized specs. + */ +type SerializedAttributes = { attrs?: Record } + +/** + * A serialized version of NodeSpec with non-serializable properties (toDOM, parseDOM, toDebugString, leafText) + * removed and attrs converted to SerializedAttributeSpec format. + */ +export type SerializedNodeSpec = Omit & + SerializedAttributes + +/** + * A serialized version of MarkSpec with non-serializable properties (toDOM, parseDOM) removed + * and attrs converted to SerializedAttributeSpec format. + */ +export type SerializedMarkSpec = Omit & SerializedAttributes + +/** + * A JSON-serializable representation of a ProseMirror Schema. + * Contains the top node name, and serialized node and mark specifications. + */ +export interface SerializedSchema { + topNode?: string + nodes: Record + marks: Record +} + +/** + * Serializes an AttributeSpec by converting the validate function to a string if it exists. + * This makes the attribute specification JSON-serializable. + * + * @param attributeSpec - The AttributeSpec to serialize + * @returns A SerializedAttributeSpec with validate as a string or undefined + */ +function serializeAttributeSpec(attributeSpec: AttributeSpec): SerializedAttributeSpec { + return { + ...omit(attributeSpec, ['validate']), + validate: typeof attributeSpec.validate === 'string' ? attributeSpec.validate : undefined, + } +} + +/** + * Serializes a record of AttributeSpec objects by converting each one using serializeAttributeSpec. + * + * @param attributeSpecs - A record of attribute names to their AttributeSpec definitions + * @returns A record of attribute names to their SerializedAttributeSpec definitions + */ +function serializeAttributeSpecs( + attributeSpecs: Record, +): Record { + return Object.fromEntries(Object.entries(attributeSpecs).map(([key, value]) => [key, serializeAttributeSpec(value)])) +} + +/** + * Serializes a NodeSpec by removing non-serializable properties (toDOM, parseDOM, toDebugString, leafText) + * and converting the attrs to SerializedAttributeSpec format. + * + * @param nodeSpec - The NodeSpec to serialize + * @returns A SerializedNodeSpec with only JSON-serializable properties + */ +function serializeNodeSpec(nodeSpec: NodeSpec): SerializedNodeSpec { + return { + ...omit(nodeSpec, ['toDOM', 'parseDOM', 'toDebugString', 'leafText', 'attrs']), + attrs: nodeSpec.attrs ? serializeAttributeSpecs(nodeSpec.attrs) : undefined, + } +} + +/** + * Serializes a MarkSpec by removing non-serializable properties (toDOM, parseDOM) + * and converting the attrs to SerializedAttributeSpec format. + * + * @param markSpec - The MarkSpec to serialize + * @returns A SerializedMarkSpec with only JSON-serializable properties + */ +function serializeMarkSpec(markSpec: MarkSpec): SerializedMarkSpec { + return { + ...omit(markSpec, ['toDOM', 'parseDOM', 'attrs']), + attrs: markSpec.attrs ? serializeAttributeSpecs(markSpec.attrs) : undefined, + } +} + +/** + * Serializes a ProseMirror Schema to a JSON-serializable format. + * Converts all node and mark specifications by removing non-serializable properties + * and converting attribute validation functions to strings. + * + * Non-serializable properties are properties related to DOM serialization and parsing, + * and functions that validate attribute values. These are just removed from the schema. + * + * @param schema - The ProseMirror Schema to serialize + * @returns A SerializedSchema containing the top node name and serialized node/mark specs + */ +export function serializeSchema(schema: Schema): SerializedSchema { + return { + topNode: schema.spec.topNode, + nodes: Object.fromEntries(Object.values(schema.nodes).map(node => [node.name, serializeNodeSpec(node.spec)])), + marks: Object.fromEntries(Object.values(schema.marks).map(mark => [mark.name, serializeMarkSpec(mark.spec)])), + } +} diff --git a/packages/server-ai-toolkit/src/server-ai-toolkit-extension.ts b/packages/server-ai-toolkit/src/server-ai-toolkit-extension.ts new file mode 100644 index 0000000000..6841998df7 --- /dev/null +++ b/packages/server-ai-toolkit/src/server-ai-toolkit-extension.ts @@ -0,0 +1,23 @@ +import { Extension } from '@tiptap/core' + +import { ServerAiToolkitHashExtension } from './hash-extension/server-ai-toolkit-hash-extension.js' + +/** + * Server AI Toolkit extension. + * + * This package-level extension registers the internal hash extension used by + * the Server AI Toolkit so server-side read and edit flows can rely on stable + * `_hash` attributes already stored in the document. + */ +export const ServerAiToolkit = Extension.create({ + name: 'serverAiToolkit', + + /** + * Registers internal extensions required by the Server AI Toolkit. + * + * @return The list of internal extensions. + */ + addExtensions() { + return [ServerAiToolkitHashExtension] + }, +}) diff --git a/packages/server-ai-toolkit/src/server-ai-toolkit.spec.ts b/packages/server-ai-toolkit/src/server-ai-toolkit.spec.ts new file mode 100644 index 0000000000..41433e623e --- /dev/null +++ b/packages/server-ai-toolkit/src/server-ai-toolkit.spec.ts @@ -0,0 +1,131 @@ +// @vitest-environment happy-dom + +import { Editor, Extension } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { describe, expect, it } from 'vitest' + +import { ServerAiToolkit } from './index.js' + +/** + * Options for creating a test editor. + */ +interface CreateEditorOptions { + /** + * Initial document JSON. + */ + content?: Record + + /** + * Additional extensions to include alongside the default ones. + */ + extensions?: Extension[] +} + +/** + * Creates an editor configured with the {@link ServerAiToolkit} extension. + * + * @param options - Editor configuration options. + * @return Promise resolving once the editor create lifecycle has finished. + */ +function createEditor(options: CreateEditorOptions): Promise { + return new Promise(resolve => { + const editor = new Editor({ + element: document.createElement('div'), + content: options.content, + extensions: [StarterKit, ServerAiToolkit, ...(options.extensions ?? [])], + onCreate: () => { + resolve(editor) + }, + }) + }) +} + +/** + * Creates an editor with an explicit extension order. + * + * @param extensions - Extensions to register in the desired order. + * @param content - Initial document JSON. + * @return Promise resolving once the editor create lifecycle has finished. + */ +function createEditorWithExplicitExtensions( + extensions: Extension[], + content?: Record, +): Promise { + return new Promise(resolve => { + const editor = new Editor({ + element: document.createElement('div'), + content, + extensions, + onCreate: () => { + resolve(editor) + }, + }) + }) +} + +const MockAiToolkit = Extension.create({ + name: 'aiToolkit', +}) + +describe('ServerAiToolkit', () => { + it('registers _hash attributes without generating values on creation', async () => { + const editor = await createEditor({ + content: { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'First' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Second' }] }, + ], + }, + }) + + const paragraphs = editor.getJSON().content ?? [] + + expect(paragraphs).toHaveLength(2) + // eslint-disable-next-line no-underscore-dangle + expect(paragraphs[0]?.attrs?._hash ?? null).toBeNull() + // eslint-disable-next-line no-underscore-dangle + expect(paragraphs[1]?.attrs?._hash ?? null).toBeNull() + + editor.destroy() + }) + + it('preserves provided hashes through a JSON round-trip', async () => { + const firstEditor = await createEditor({ + content: { + type: 'doc', + content: [{ type: 'paragraph', attrs: { _hash: 'ABC123' }, content: [{ type: 'text', text: 'Hello' }] }], + }, + }) + + const firstJson = firstEditor.getJSON() + // eslint-disable-next-line no-underscore-dangle + const firstHash = firstJson.content?.[0]?.attrs?._hash + + firstEditor.destroy() + + const secondEditor = await createEditor({ + content: firstJson, + }) + + const secondJson = secondEditor.getJSON() + // eslint-disable-next-line no-underscore-dangle + const secondHash = secondJson.content?.[0]?.attrs?._hash + + expect(secondHash).toBe(firstHash) + + secondEditor.destroy() + }) + + it('does not synthesize hashes when the AI Toolkit extension is present', async () => { + const editor = await createEditorWithExplicitExtensions([StarterKit, ServerAiToolkit, MockAiToolkit], { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }], + }) + + // eslint-disable-next-line no-underscore-dangle + expect(editor.getJSON().content?.[0]?.attrs?._hash ?? null).toBeNull() + + editor.destroy() + }) +}) diff --git a/packages/server-ai-toolkit/tsup.config.ts b/packages/server-ai-toolkit/tsup.config.ts new file mode 100644 index 0000000000..03b7c8d0b6 --- /dev/null +++ b/packages/server-ai-toolkit/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'], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d9d32c0a8..1a510fd074 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1055,6 +1055,22 @@ importers: specifier: ^19.0.0 version: 19.1.0(react@19.1.0) + packages/server-ai-toolkit: + dependencies: + es-toolkit: + specifier: ^1.41.0 + version: 1.46.1 + zod: + specifier: ^4.1.8 + version: 4.4.3 + devDependencies: + '@tiptap/core': + specifier: workspace:^ + version: link:../core + '@tiptap/pm': + specifier: workspace:^ + version: link:../pm + packages/starter-kit: dependencies: '@tiptap/core': @@ -4371,6 +4387,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + esbuild-android-64@0.15.18: resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==} engines: {node: '>=12'} @@ -7490,6 +7509,9 @@ packages: zod@3.24.1: resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -11085,6 +11107,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.46.1: {} + esbuild-android-64@0.15.18: optional: true @@ -14321,4 +14345,6 @@ snapshots: zod@3.24.1: {} + zod@4.4.3: {} + zwitch@2.0.4: {} From 032bbcac78702eeb2b1e41accf920a100cfcc873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnau=20G=C3=B3mez=20Farell?= Date: Tue, 12 May 2026 12:18:06 +0200 Subject: [PATCH 3/6] feat: set the server ai toolkit package as not fixed --- .changeset/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/config.json b/.changeset/config.json index 7828f76af3..218701bb10 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/@changesets/config@3.0.1/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, - "fixed": [["@tiptap/*"]], + "fixed": [["@tiptap/*", "!@tiptap/server-ai-toolkit"]], "linked": [], "access": "public", "baseBranch": "main", From a1dc816d314b2cc239ca24841d6f0ac70d65107c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnau=20G=C3=B3mez=20Farell?= Date: Tue, 12 May 2026 12:22:42 +0200 Subject: [PATCH 4/6] fix: delete dangling file --- packages/server-ai-toolkit/src/README.md | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 packages/server-ai-toolkit/src/README.md diff --git a/packages/server-ai-toolkit/src/README.md b/packages/server-ai-toolkit/src/README.md deleted file mode 100644 index 7094e58d3e..0000000000 --- a/packages/server-ai-toolkit/src/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# @tiptap/react - -[![Version](https://img.shields.io/npm/v/@tiptap/react.svg?label=version)](https://www.npmjs.com/package/@tiptap/react) -[![Downloads](https://img.shields.io/npm/dm/@tiptap/react.svg)](https://npmcharts.com/compare/tiptap?minimal=true) -[![License](https://img.shields.io/npm/l/@tiptap/react.svg)](https://www.npmjs.com/package/@tiptap/react) -[![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). From e6842b3ab59612c0d234763235310f1903e7a1b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnau=20G=C3=B3mez=20Farell?= Date: Tue, 12 May 2026 12:43:50 +0200 Subject: [PATCH 5/6] fix(server-ai-toolkit): align lint script --- packages/server-ai-toolkit/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server-ai-toolkit/package.json b/packages/server-ai-toolkit/package.json index e79bc64376..fa8e24b78d 100644 --- a/packages/server-ai-toolkit/package.json +++ b/packages/server-ai-toolkit/package.json @@ -53,6 +53,6 @@ "scripts": { "build": "tsup", "dev": "tsup --watch --sourcemap", - "lint": "biome check ./src/" + "lint": "prettier ./src/ --check && eslint --cache --quiet --no-error-on-unmatched-pattern ./src/" } } From c3a9a6f67ebe9e60658f381051bc60d1ecec318d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnau=20G=C3=B3mez=20Farell?= Date: Tue, 12 May 2026 14:09:28 +0200 Subject: [PATCH 6/6] chore: formatting fixes --- CHANGELOG.md | 3 ++- packages/vue-3/__tests__/useEditor.spec.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd205c762..9aa3a7ac45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,9 @@ #### Patch Changes - a4e5154: Fix `DragHandle` unmounts by rendering children into the plugin-managed drag handle element with a React portal. - + This avoids React trying to remove a host node after the drag handle plugin has moved it into its own wrapper. + - @tiptap/extension-drag-handle@3.23.1 - @tiptap/pm@3.23.1 - @tiptap/react@3.23.1 diff --git a/packages/vue-3/__tests__/useEditor.spec.ts b/packages/vue-3/__tests__/useEditor.spec.ts index f4301f6344..08a32790a4 100644 --- a/packages/vue-3/__tests__/useEditor.spec.ts +++ b/packages/vue-3/__tests__/useEditor.spec.ts @@ -25,6 +25,7 @@ describe('useEditor', () => { try { const TestComponent = defineComponent({ setup() { + // eslint-disable-next-line react-hooks/rules-of-hooks const editor = useEditor({ extensions: [Document, Paragraph, Text], content: '

Hello World

',