diff --git a/docs/content/docs/features/server-processing.mdx b/docs/content/docs/features/server-processing.mdx index a766fa7a15..d970074838 100644 --- a/docs/content/docs/features/server-processing.mdx +++ b/docs/content/docs/features/server-processing.mdx @@ -12,15 +12,29 @@ While you can use the `BlockNoteEditor` on the client side, you can also use `Se For example, use the following code to convert a BlockNote document to HTML on the server: ```tsx -import { ServerBlockNoteEditor } from "@blocknote/server-util"; - -const editor = ServerBlockNoteEditor.create(); +import { ServerBlockNoteEditor, DomShim } from "@blocknote/server-util"; +import { JSDOM } from "jsdom"; + +// Create a DOM shim (you can use jsdom, happydom, or any other DOM implementation) +const jsdomShim: DomShim = { + acquire() { + const dom = new JSDOM(); + return { + window: dom.window as any, + document: dom.window.document as any, + }; + }, +}; + +const editor = ServerBlockNoteEditor.create({}, jsdomShim); const html = await editor.blocksToFullHTML(blocks); ``` `ServerBlockNoteEditor.create` takes the same BlockNoteEditorOptions as `useCreateBlockNote` and `BlockNoteEditor.create` ([see docs](/docs/getting-started)), so you can pass the same configuration (for example, your custom schema) to your server-side BlockNote editor as on the client. +**Note:** Methods that require DOM (like `blocksToFullHTML`, `blocksToHTMLLossy`, `blocksToMarkdownLossy`, etc.) require a `DomShim` to be provided. You can use any DOM implementation (jsdom, happydom, etc.) by implementing the `DomShim` interface. + ## Functions for converting blocks `ServerBlockNoteEditor` exposes the same functions for converting blocks as the client side editor ([HTML](/docs/features/import/html), [Markdown](/docs/features/import/markdown)): diff --git a/examples/02-backend/04-rendering-static-documents/src/App.tsx b/examples/02-backend/04-rendering-static-documents/src/App.tsx index c3121833ae..d20b3850e3 100644 --- a/examples/02-backend/04-rendering-static-documents/src/App.tsx +++ b/examples/02-backend/04-rendering-static-documents/src/App.tsx @@ -4,9 +4,20 @@ import "@blocknote/mantine/style.css"; /** On Server Side, you can use the ServerBlockNoteEditor to render BlockNote documents to HTML. e.g.: - import { ServerBlockNoteEditor } from "@blocknote/server-util"; + import { ServerBlockNoteEditor, DomShim } from "@blocknote/server-util"; + import { JSDOM } from "jsdom"; - const editor = ServerBlockNoteEditor.create(); + const jsdomShim: DomShim = { + acquire() { + const dom = new JSDOM(); + return { + window: dom.window as any, + document: dom.window.document as any, + }; + }, + }; + + const editor = ServerBlockNoteEditor.create({}, jsdomShim); const html = await editor.blocksToFullHTML(document); You can then use render this HTML as a static page or send it to the client. Make sure to include the editor stylesheets: diff --git a/packages/server-util/package.json b/packages/server-util/package.json index 06a7fbbba0..2c7f64016c 100644 --- a/packages/server-util/package.json +++ b/packages/server-util/package.json @@ -60,7 +60,6 @@ "@blocknote/react": "0.42.0", "@tiptap/core": "^3.10.2", "@tiptap/pm": "^3.10.2", - "jsdom": "^25.0.1", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.6", "yjs": "^13.6.27" @@ -70,6 +69,7 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "eslint": "^8.57.1", + "jsdom": "^25.0.1", "react": "^19.2.0", "react-dom": "^19.2.0", "rollup-plugin-webpack-stats": "^0.2.6", diff --git a/packages/server-util/src/context/ServerBlockNoteEditor.test.ts b/packages/server-util/src/context/ServerBlockNoteEditor.test.ts index f5729df9eb..e3ebe0ee18 100644 --- a/packages/server-util/src/context/ServerBlockNoteEditor.test.ts +++ b/packages/server-util/src/context/ServerBlockNoteEditor.test.ts @@ -1,9 +1,20 @@ import { Block } from "@blocknote/core"; import { describe, expect, it } from "vitest"; -import { ServerBlockNoteEditor } from "./ServerBlockNoteEditor.js"; +import { JSDOM } from "jsdom"; +import { DomShim, ServerBlockNoteEditor } from "./ServerBlockNoteEditor.js"; + +const jsdomShim: DomShim = { + acquire() { + const dom = new JSDOM(); + return { + window: dom.window as any, + document: dom.window.document as any, + }; + }, +}; describe("Test ServerBlockNoteEditor", () => { - const editor = ServerBlockNoteEditor.create(); + const editor = ServerBlockNoteEditor.create({}, jsdomShim); const blocks: Block[] = [ { @@ -124,3 +135,146 @@ describe("Test ServerBlockNoteEditor", () => { expect(blockOutput).toMatchSnapshot(); }); }); + +describe("ServerBlockNoteEditor with domShim", () => { + it("uses provided domShim correctly", async () => { + let acquireCalled = false; + let releaseCalled = false; + let releasedGlobals: any = null; + + const testShim: DomShim = { + acquire() { + acquireCalled = true; + const dom = new JSDOM(); + return { + window: dom.window as any, + document: dom.window.document as any, + }; + }, + release(globals) { + releaseCalled = true; + releasedGlobals = globals; + }, + }; + + const editor = ServerBlockNoteEditor.create({}, testShim); + const blocks: Block[] = [ + { + id: "1", + type: "paragraph", + props: { + backgroundColor: "default", + textColor: "default", + textAlignment: "left", + }, + content: [ + { + type: "text", + text: "Test", + styles: {}, + }, + ], + children: [], + }, + ]; + + const html = await editor.blocksToFullHTML(blocks); + + expect(acquireCalled).toBe(true); + expect(releaseCalled).toBe(true); + expect(releasedGlobals).toBeTruthy(); + expect(releasedGlobals.document).toBeTruthy(); + expect(releasedGlobals.window).toBeTruthy(); + expect(html).toBeTruthy(); + }); + + it("throws error when no domShim is provided and globals don't exist", async () => { + // Save original globals + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + + try { + // Remove globals to simulate server environment + delete (globalThis as any).window; + delete (globalThis as any).document; + + const editor = ServerBlockNoteEditor.create(); + const blocks: Block[] = [ + { + id: "1", + type: "paragraph", + props: { + backgroundColor: "default", + textColor: "default", + textAlignment: "left", + }, + content: [ + { + type: "text", + text: "Test", + styles: {}, + }, + ], + children: [], + }, + ]; + + await expect(editor.blocksToFullHTML(blocks)).rejects.toThrow( + "DOM globals (window/document) are required but not available", + ); + + await expect(editor.blocksToHTMLLossy(blocks)).rejects.toThrow( + "DOM globals (window/document) are required but not available", + ); + + await expect(editor.blocksToMarkdownLossy(blocks)).rejects.toThrow( + "DOM globals (window/document) are required but not available", + ); + } finally { + // Restore original globals + globalThis.window = originalWindow; + globalThis.document = originalDocument; + } + }); + + it("works when globals already exist without domShim", async () => { + // This test verifies that if window/document already exist globally, + // methods work without a domShim + // Note: This test only works if the test environment has globals set up + // (e.g., via jsdom in vitest config) + if ( + typeof globalThis.window !== "undefined" && + typeof globalThis.document !== "undefined" + ) { + const editor = ServerBlockNoteEditor.create(); + const blocks: Block[] = [ + { + id: "1", + type: "paragraph", + props: { + backgroundColor: "default", + textColor: "default", + textAlignment: "left", + }, + content: [ + { + type: "text", + text: "Test", + styles: {}, + }, + ], + children: [], + }, + ]; + + // Should work if globals exist (like in a test environment with jsdom) + const html = await editor.blocksToFullHTML(blocks); + // eslint-disable-next-line jest/no-conditional-expect + expect(html).toBeTruthy(); + } else { + // Skip test if globals don't exist in this environment + // eslint-disable-next-line jest/no-conditional-expect + expect(true).toBe(true); + } + }); +}); diff --git a/packages/server-util/src/context/ServerBlockNoteEditor.ts b/packages/server-util/src/context/ServerBlockNoteEditor.ts index 3068bf2f39..c811655358 100644 --- a/packages/server-util/src/context/ServerBlockNoteEditor.ts +++ b/packages/server-util/src/context/ServerBlockNoteEditor.ts @@ -25,13 +25,55 @@ import { import { BlockNoteViewRaw } from "@blocknote/react"; import { Node } from "@tiptap/pm/model"; -import * as jsdom from "jsdom"; import * as React from "react"; import { createElement } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import type * as Y from "yjs"; +/** + * Globals that a DOM shim should provide + */ +export type DomGlobals = { window: any; document: any }; + +/** + * Interface for DOM shims that can be used with ServerBlockNoteEditor. + * + * Example with jsdom: + * ```ts + * import { JSDOM } from 'jsdom'; + * const jsdomShim: DomShim = { + * acquire() { + * const dom = new JSDOM(); + * return { window: dom.window as any, document: dom.window.document as any }; + * }, + * }; + * ``` + * + * Example with happydom: + * ```ts + * import { Window } from 'happy-dom'; + * const happydomShim: DomShim = { + * acquire() { + * const window = new Window(); + * return { window: window as any, document: window.document as any }; + * }, + * }; + * ``` + */ +export interface DomShim { + /** + * Acquire DOM globals (window and document) for use during the operation. + * Can return synchronously or asynchronously. + */ + acquire(): Promise | DomGlobals; + /** + * Optional cleanup method called after the operation completes. + * Can be synchronous or asynchronous. + */ + release?(globals: DomGlobals): Promise | void; +} + /** * Use the ServerBlockNoteEditor to interact with BlockNote documents in a server (nodejs) environment. */ @@ -46,33 +88,87 @@ export class ServerBlockNoteEditor< public readonly editor: BlockNoteEditor; /** - * We currently use a JSDOM instance to mock document and window methods - * - * A possible improvement could be to make this: - * a) pluggable so other shims can be used as well - * b) obsolete, but for this all blocks should be React based and we need to remove all references to document / window - * from the core / react package. (and even then, it's likely some custom blocks would still use document / window methods) + * Optional DOM shim for providing window and document in server environments. + * If not provided, methods that require DOM will throw errors when accessed. */ - private jsdom = new jsdom.JSDOM(); + private readonly domShim?: DomShim; /** - * Calls a function with mocking window and document using JSDOM - * - * We could make this obsolete by passing in a document / window object to the render / serialize methods of Blocks + * Calls a function with DOM globals (window and document) provided by the shim. + * If no shim is provided and globals don't exist, throws errors on access rather than immediately. + * The callback receives document and window as arguments for easier access. */ - public async _withJSDOM(fn: () => Promise) { + public async _withDOM( + fn: (globals: DomGlobals) => Promise, + ): Promise { const prevWindow = globalThis.window; const prevDocument = globalThis.document; - globalThis.document = this.jsdom.window.document; - (globalThis as any).window = this.jsdom.window; - (globalThis as any).window.__TEST_OPTIONS = ( - prevWindow as any - )?.__TEST_OPTIONS; + + let acquiredGlobals: DomGlobals | undefined; + let document: any; + let window: any; + + if (this.domShim) { + const result = this.domShim.acquire(); + acquiredGlobals = result instanceof Promise ? await result : result; + document = acquiredGlobals.document; + window = acquiredGlobals.window; + globalThis.document = document; + (globalThis as any).window = window; + // Preserve __TEST_OPTIONS if it existed + (globalThis as any).window.__TEST_OPTIONS = ( + prevWindow as any + )?.__TEST_OPTIONS; + } else if (prevWindow && prevDocument) { + // Globals already exist, use them as-is + document = prevDocument; + window = prevWindow; + } else { + // No shim and no globals - create proxy objects that throw on access + const errorMessage = + "DOM globals (window/document) are required but not available. " + + "Please provide a DomShim when creating ServerBlockNoteEditor, " + + "or ensure window/document are available globally."; + + const throwingProxy = new Proxy( + {}, + { + get() { + throw new Error(errorMessage); + }, + set() { + throw new Error(errorMessage); + }, + has() { + throw new Error(errorMessage); + }, + ownKeys() { + throw new Error(errorMessage); + }, + getOwnPropertyDescriptor() { + throw new Error(errorMessage); + }, + }, + ); + + document = throwingProxy as any; + window = throwingProxy as any; + globalThis.document = document; + (globalThis as any).window = window; + } + try { - return await fn(); + return await fn({ document, window }); } finally { globalThis.document = prevDocument; globalThis.window = prevWindow; + + if (this.domShim && acquiredGlobals && this.domShim.release) { + const releaseResult = this.domShim.release(acquiredGlobals); + if (releaseResult instanceof Promise) { + await releaseResult; + } + } } } @@ -80,8 +176,11 @@ export class ServerBlockNoteEditor< BSchema extends BlockSchema = DefaultBlockSchema, ISchema extends InlineContentSchema = DefaultInlineContentSchema, SSchema extends StyleSchema = DefaultStyleSchema, - >(options: Partial> = {}) { - return new ServerBlockNoteEditor(options) as ServerBlockNoteEditor< + >( + options: Partial> = {}, + domShim?: DomShim, + ) { + return new ServerBlockNoteEditor(options, domShim) as ServerBlockNoteEditor< BSchema, ISchema, SSchema @@ -90,8 +189,10 @@ export class ServerBlockNoteEditor< protected constructor( options: Partial>, + domShim?: DomShim, ) { this.editor = BlockNoteEditor.create(options) as any; + this.domShim = domShim; } /** PROSEMIRROR / BLOCKNOTE conversions */ @@ -186,14 +287,14 @@ export class ServerBlockNoteEditor< public async blocksToHTMLLossy( blocks: PartialBlock[], ): Promise { - return this._withJSDOM(async () => { + return this._withDOM(async ({ document }) => { const exporter = createExternalHTMLExporter( this.editor.pmSchema, this.editor, ); return exporter.exportBlocks(blocks, { - document: this.jsdom.window.document, + document, }); }); } @@ -210,14 +311,14 @@ export class ServerBlockNoteEditor< public async blocksToFullHTML( blocks: PartialBlock[], ): Promise { - return this._withJSDOM(async () => { + return this._withDOM(async ({ document }) => { const exporter = createInternalHTMLSerializer( this.editor.pmSchema, this.editor, ); return exporter.serializeBlocks(blocks, { - document: this.jsdom.window.document, + document, }); }); } @@ -232,7 +333,7 @@ export class ServerBlockNoteEditor< public async tryParseHTMLToBlocks( html: string, ): Promise[]> { - return this._withJSDOM(async () => { + return this._withDOM(async () => { return this.editor.tryParseHTMLToBlocks(html); }); } @@ -248,9 +349,9 @@ export class ServerBlockNoteEditor< public async blocksToMarkdownLossy( blocks: PartialBlock[], ): Promise { - return this._withJSDOM(async () => { + return this._withDOM(async ({ document }) => { return blocksToMarkdown(blocks, this.editor.pmSchema, this.editor, { - document: this.jsdom.window.document, + document, }); }); } @@ -265,7 +366,7 @@ export class ServerBlockNoteEditor< public async tryParseMarkdownToBlocks( markdown: string, ): Promise[]> { - return this._withJSDOM(async () => { + return this._withDOM(async () => { return this.editor.tryParseMarkdownToBlocks(markdown); }); } @@ -285,10 +386,8 @@ export class ServerBlockNoteEditor< ); */ public async withReactContext(comp: React.FC, fn: () => Promise) { - return this._withJSDOM(async () => { - const tmpRoot = createRoot( - this.jsdom.window.document.createElement("div"), - ); + return this._withDOM(async ({ document }) => { + const tmpRoot = createRoot(document.createElement("div")); flushSync(() => { tmpRoot.render( diff --git a/packages/server-util/src/context/react/ReactServer.test.tsx b/packages/server-util/src/context/react/ReactServer.test.tsx index 57c66709e1..1a050f9cf0 100644 --- a/packages/server-util/src/context/react/ReactServer.test.tsx +++ b/packages/server-util/src/context/react/ReactServer.test.tsx @@ -6,7 +6,21 @@ import { import { createReactBlockSpec } from "@blocknote/react"; import { createContext, useContext } from "react"; import { describe, expect, it } from "vitest"; -import { ServerBlockNoteEditor } from "../ServerBlockNoteEditor.js"; +import { JSDOM } from "jsdom"; +import { + DomShim, + ServerBlockNoteEditor, +} from "../ServerBlockNoteEditor.js"; + +const jsdomShim: DomShim = { + acquire() { + const dom = new JSDOM(); + return { + window: dom.window as any, + document: dom.window.document as any, + }; + }, +}; const SimpleReactCustomParagraph = createReactBlockSpec( { @@ -53,9 +67,12 @@ const schema = BlockNoteSchema.create({ describe("Test ServerBlockNoteEditor with React blocks", () => { it("works for simple blocks", async () => { - const editor = ServerBlockNoteEditor.create({ - schema, - }); + const editor = ServerBlockNoteEditor.create( + { + schema, + }, + jsdomShim, + ); const html = await editor.blocksToFullHTML([ { id: "1", @@ -67,9 +84,12 @@ describe("Test ServerBlockNoteEditor with React blocks", () => { }); it("works for blocks with context", async () => { - const editor = ServerBlockNoteEditor.create({ - schema, - }); + const editor = ServerBlockNoteEditor.create( + { + schema, + }, + jsdomShim, + ); const html = await editor.withReactContext( ({ children }) => ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3579e735e1..9f63af185b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4792,9 +4792,6 @@ importers: '@tiptap/pm': specifier: ^3.0.0 version: 3.10.2 - jsdom: - specifier: ^25.0.1 - version: 25.0.1(canvas@2.11.2(encoding@0.1.13)) y-prosemirror: specifier: ^1.3.7 version: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.3)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) @@ -4817,6 +4814,9 @@ importers: eslint: specifier: ^8.57.1 version: 8.57.1 + jsdom: + specifier: ^25.0.1 + version: 25.0.1(canvas@2.11.2(encoding@0.1.13)) react: specifier: ^19.2.0 version: 19.2.0