From b2bfd9853a5d92c79cc674fbf24db6e640fc3966 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 29 Jun 2026 11:37:33 +0200 Subject: [PATCH 1/4] feat(core): de-duplicate and order extensions by key in ExtensionManager De-duplication: when an extension with a given key is already registered, skip registering another one with the same key (the first registration wins). This lets an extension declare a dependency on another via `blockNoteExtensions` without conflicting when that same extension is also registered directly by the user. Ordering: a sub-extension declared via `blockNoteExtensions` is a dependency of the extension that declares it, so it now runs before its parent. The dependency is recorded as the sub is resolved (before the de-duplication check), so it holds even when multiple parents declare the same sub-extension and when a parent has a higher base priority via `runsBefore`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ExtensionManager/ExtensionManager.test.ts | 249 ++++++++++++++++++ .../editor/managers/ExtensionManager/index.ts | 55 +++- 2 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts diff --git a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts new file mode 100644 index 0000000000..4c97e8849c --- /dev/null +++ b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts @@ -0,0 +1,249 @@ +/** + * @vitest-environment jsdom + */ +import { Plugin, PluginKey } from "prosemirror-state"; +import { describe, expect, it } from "vite-plus/test"; + +import { createExtension } from "../../BlockNoteExtension.js"; +import { BlockNoteEditor } from "../../BlockNoteEditor.js"; + +function createMountedEditor( + extensions: BlockNoteEditor["options"]["extensions"], +) { + const editor = BlockNoteEditor.create({ extensions }); + editor.mount(document.createElement("div")); + return editor; +} + +/** + * Returns the index of the plugin identified by `key` within the editor's + * ProseMirror plugin list. A lower index means it runs/applies earlier. + */ +function pluginIndex( + editor: BlockNoteEditor, + key: PluginKey, +): number { + return editor.prosemirrorState.plugins.findIndex( + (plugin) => (plugin as any).spec?.key === key, + ); +} + +describe("ExtensionManager de-duplication by key", () => { + it("registers only the first extension when two share a key", () => { + let mountCount = 0; + + const first = createExtension(() => ({ + key: "dup", + value: "first", + mount() { + mountCount++; + return () => {}; + }, + })); + const second = createExtension(() => ({ + key: "dup", + value: "second", + mount() { + mountCount++; + return () => {}; + }, + })); + + const editor = createMountedEditor([first(), second()]); + + // The first registration wins. + expect(editor.getExtension(first)?.value).toBe("first"); + // The second registration was skipped entirely. + expect(editor.getExtension(second)).toBeUndefined(); + expect((editor.extensions.get("dup") as any)?.value).toBe("first"); + expect( + [...editor.extensions.values()].filter((e) => e.key === "dup").length, + ).toBe(1); + // Only the registered extension was mounted. + expect(mountCount).toBe(1); + }); + + it("does not re-register a dependency declared via blockNoteExtensions when it is already registered", () => { + // Two distinct factories sharing the key "dep". + const depDirect = createExtension(() => ({ + key: "dep", + value: "direct", + })); + const depFromParent = createExtension(() => ({ + key: "dep", + value: "from-parent", + })); + const parent = createExtension(() => ({ + key: "parent", + blockNoteExtensions: [depFromParent()], + })); + + // Register the dependency directly first, then a parent that also pulls in + // its own "dep" via blockNoteExtensions. + const editor = createMountedEditor([depDirect(), parent()]); + + expect(editor.getExtension(parent)).toBeDefined(); + // The directly-registered dependency wins; the one declared by the parent + // is skipped rather than overriding it. + expect(editor.getExtension(depDirect)?.value).toBe("direct"); + expect(editor.getExtension(depFromParent)).toBeUndefined(); + expect((editor.extensions.get("dep") as any)?.value).toBe("direct"); + }); + + it("registers a dependency declared via blockNoteExtensions when it isn't registered otherwise", () => { + const dep = createExtension(() => ({ + key: "lonely-dep", + value: "dep", + })); + const parent = createExtension(() => ({ + key: "lonely-parent", + blockNoteExtensions: [dep()], + })); + + const editor = createMountedEditor([parent()]); + + expect(editor.getExtension(parent)).toBeDefined(); + expect(editor.getExtension(dep)?.value).toBe("dep"); + }); +}); + +describe("ExtensionManager ordering", () => { + it("orders an extension before another it declares in runsBefore", () => { + const firstKey = new PluginKey("rb-first"); + const secondKey = new PluginKey("rb-second"); + + const first = createExtension(() => ({ + key: "rb-first", + runsBefore: ["rb-second"], + prosemirrorPlugins: [new Plugin({ key: firstKey })], + })); + const second = createExtension(() => ({ + key: "rb-second", + prosemirrorPlugins: [new Plugin({ key: secondKey })], + })); + + // Register in the "wrong" order to prove runsBefore — not array order — + // determines precedence. + const editor = createMountedEditor([second(), first()]); + + expect(pluginIndex(editor, firstKey)).toBeLessThan( + pluginIndex(editor, secondKey), + ); + }); + + it("flattens sub-extensions and runs the parent after its blockNoteExtensions dependency", () => { + const subKey = new PluginKey("sub-order"); + const parentKey = new PluginKey("parent-order"); + + const sub = createExtension(() => ({ + key: "ordered-sub", + prosemirrorPlugins: [new Plugin({ key: subKey })], + })); + const parent = createExtension(() => ({ + key: "ordered-parent", + blockNoteExtensions: [sub()], + prosemirrorPlugins: [new Plugin({ key: parentKey })], + })); + + const editor = createMountedEditor([parent()]); + + // The sub-extension is flattened into the editor's extensions... + expect(editor.getExtension(sub)).toBeDefined(); + expect(editor.getExtension(parent)).toBeDefined(); + + // ...and because the parent declares the sub as a dependency, the sub runs + // before the parent (even though the parent is registered first). + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentKey), + ); + }); + + it("forces a blockNoteExtensions dependency before a parent that has a higher base priority", () => { + // The parent declares `runsBefore` on an unrelated extension, which raises + // its priority above the default. Without an explicit dependency edge, the + // higher-priority parent would run before its sub. The dependency must + // override that so the sub still runs first. + const subKey = new PluginKey("forced-sub"); + const parentKey = new PluginKey("forced-parent"); + const otherKey = new PluginKey("forced-other"); + + const other = createExtension(() => ({ + key: "forced-other", + prosemirrorPlugins: [new Plugin({ key: otherKey })], + })); + const sub = createExtension(() => ({ + key: "forced-sub", + prosemirrorPlugins: [new Plugin({ key: subKey })], + })); + const parent = createExtension(() => ({ + key: "forced-parent", + runsBefore: ["forced-other"], + blockNoteExtensions: [sub()], + prosemirrorPlugins: [new Plugin({ key: parentKey })], + })); + + const editor = createMountedEditor([parent(), other()]); + + // The parent runs before the unrelated extension (its declared runsBefore)... + expect(pluginIndex(editor, parentKey)).toBeLessThan( + pluginIndex(editor, otherKey), + ); + // ...but its dependency still runs before it. + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentKey), + ); + }); + + it("runs a shared sub-dependency before both extensions that declare it", () => { + const subKey = new PluginKey("shared-sub"); + const parentAKey = new PluginKey("shared-parent-a"); + const parentBKey = new PluginKey("shared-parent-b"); + const otherKey = new PluginKey("shared-other"); + + const other = createExtension(() => ({ + key: "shared-other", + prosemirrorPlugins: [new Plugin({ key: otherKey })], + })); + // A single sub-extension instance declared by two different parents. It is + // registered once (de-duplicated) and must run before both parents. + const sharedSub = createExtension(() => ({ + key: "shared-sub", + prosemirrorPlugins: [new Plugin({ key: subKey })], + })); + const parentA = createExtension(() => ({ + key: "shared-parent-a", + blockNoteExtensions: [sharedSub()], + prosemirrorPlugins: [new Plugin({ key: parentAKey })], + })); + // parentB declares the *already-registered* sub (so its registration is + // de-duplicated) and has a higher base priority via runsBefore. The + // dependency must still be recorded on the de-duplicated path so the sub + // runs before parentB too. + const parentB = createExtension(() => ({ + key: "shared-parent-b", + runsBefore: ["shared-other"], + blockNoteExtensions: [sharedSub()], + prosemirrorPlugins: [new Plugin({ key: parentBKey })], + })); + + const editor = createMountedEditor([parentA(), parentB(), other()]); + + // The sub is registered exactly once despite being declared twice. + expect( + [...editor.extensions.values()].filter((e) => e.key === "shared-sub") + .length, + ).toBe(1); + + // parentB's higher base priority puts it before the unrelated extension... + expect(pluginIndex(editor, parentBKey)).toBeLessThan( + pluginIndex(editor, otherKey), + ); + // ...but the shared sub still runs before both parents. + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentAKey), + ); + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentBKey), + ); + }); +}); diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index c49f787f57..17d1b46083 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -49,6 +49,12 @@ export class ExtensionManager { * We need to keep track of all the plugins for each extension, so that we can remove them when the extension is unregistered */ private extensionPlugins: Map = new Map(); + /** + * Maps an extension key to the set of extension keys that declared it as a + * dependency via `blockNoteExtensions`. A sub-extension is a dependency of + * the extension that declares it, so it must run *before* its parent(s). + */ + private blockNoteExtensionDependents: Map> = new Map(); constructor( private editor: BlockNoteEditor, @@ -179,6 +185,12 @@ export class ExtensionManager { */ private addExtension( extension: Extension | ExtensionFactoryInstance, + /** + * When this extension is being added as a dependency declared in another + * extension's `blockNoteExtensions`, this is the key of that declaring + * (parent) extension. + */ + parentKey?: string, ): Extension | undefined { let instance: Extension; if (typeof extension === "function") { @@ -191,6 +203,29 @@ export class ExtensionManager { return undefined as any; } + // A sub-extension declared via `blockNoteExtensions` must run before the + // extension that declares it. We record this dependency before the + // de-duplication check below, so that it applies even when multiple + // extensions declare the same sub-extension (and all but the first are + // de-duplicated). + if (parentKey) { + let dependents = this.blockNoteExtensionDependents.get(instance.key); + if (!dependents) { + dependents = new Set(); + this.blockNoteExtensionDependents.set(instance.key, dependents); + } + dependents.add(parentKey); + } + + // De-duplicate by key: if an extension with the same key is already + // registered, don't register it again. This allows an extension to declare + // a dependency on another extension via `blockNoteExtensions` without + // conflicting when the user (or another extension) registers that same + // extension directly. The first registration wins. + if (this.extensions.some((e) => e.key === instance.key)) { + return undefined as any; + } + // Now that we know that the extension is not disabled, we can add it to the extension factories if (typeof extension === "function") { const originalFactory = (instance as any)[originalFactorySymbol] as ( @@ -205,8 +240,8 @@ export class ExtensionManager { this.extensions.push(instance); if (instance.blockNoteExtensions) { - for (const extension of instance.blockNoteExtensions) { - this.addExtension(extension); + for (const subExtension of instance.blockNoteExtensions) { + this.addExtension(subExtension, instance.key); } } @@ -326,7 +361,21 @@ export class ExtensionManager { this.options, ).filter((extension) => !this.disabledExtensions.has(extension.name)); - const getPriority = sortByDependencies(this.extensions); + const getPriority = sortByDependencies( + this.extensions.map((extension) => { + // A sub-extension declared via `blockNoteExtensions` must run before the + // extension(s) that declared it, so we merge those parents into its + // `runsBefore`. + const dependents = this.blockNoteExtensionDependents.get(extension.key); + if (!dependents?.size) { + return extension; + } + return { + key: extension.key, + runsBefore: [...(extension.runsBefore ?? []), ...dependents], + }; + }), + ); const inputRulesByPriority = new Map(); for (const extension of this.extensions) { From 54522f2c6dda6386ca1000cad92c761ddcee7f98 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 29 Jun 2026 15:25:48 +0200 Subject: [PATCH 2/4] refactor: move syntax highlighting to singleton extension, code-block & math block can configure a highlighter for --- examples/04-theming/06-code-block/src/App.tsx | 5 +-- .../09-math-block/src/App.tsx | 12 ++----- packages/code-block/src/index.test.ts | 5 +-- packages/code-block/src/index.ts | 14 ++++---- .../core/src/blocks/Code/CodeBlockOptions.ts | 4 +-- packages/core/src/blocks/Code/block.ts | 8 ++++- .../managers/ExtensionManager/extensions.ts | 5 --- .../SyntaxHighlighting/SyntaxHighlighting.ts | 35 +++++++------------ .../extensions/SyntaxHighlighting/shiki.ts | 17 ++++++--- packages/core/src/schema/blocks/types.ts | 12 +++++-- .../core/src/schema/inlineContent/internal.ts | 10 +++++- .../core/src/schema/inlineContent/types.ts | 27 +++++++++++--- packages/math-block/src/block.ts | 27 +++++++++----- packages/math-block/src/reactBlock.tsx | 1 + 14 files changed, 106 insertions(+), 76 deletions(-) diff --git a/examples/04-theming/06-code-block/src/App.tsx b/examples/04-theming/06-code-block/src/App.tsx index a757bada0d..82d10bae9e 100644 --- a/examples/04-theming/06-code-block/src/App.tsx +++ b/examples/04-theming/06-code-block/src/App.tsx @@ -4,14 +4,11 @@ import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; // This packages some of the most used languages in on-demand bundle -import { codeBlockOptions, createHighlighter } from "@blocknote/code-block"; +import { codeBlockOptions } from "@blocknote/code-block"; export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ - // The Shiki highlighter is configured at the editor level, separately from - // the code block's own options (default language & language menu). - syntaxHighlighting: { createHighlighter }, schema: BlockNoteSchema.create().extend({ blockSpecs: { codeBlock: createCodeBlockSpec(codeBlockOptions), diff --git a/examples/06-custom-schema/09-math-block/src/App.tsx b/examples/06-custom-schema/09-math-block/src/App.tsx index eb9838f279..6007d735d8 100644 --- a/examples/06-custom-schema/09-math-block/src/App.tsx +++ b/examples/06-custom-schema/09-math-block/src/App.tsx @@ -1,10 +1,10 @@ import "@blocknote/core/fonts/inter.css"; -import { BlockNoteSchema } from "@blocknote/core"; +import { BlockNoteSchema, createCodeBlockSpec } from "@blocknote/core"; import { filterSuggestionItems, insertOrUpdateBlockForSlashMenu, } from "@blocknote/core/extensions"; -import { createHighlighter } from "@blocknote/code-block"; +import { codeBlockOptions } from "@blocknote/code-block"; import { createReactMathBlockSpec } from "@blocknote/math-block"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; @@ -19,6 +19,7 @@ import { TbMathFunction } from "react-icons/tb"; // that we want our editor to use. const schema = BlockNoteSchema.create().extend({ blockSpecs: { + codeBlock: createCodeBlockSpec(codeBlockOptions), // Creates an instance of the Math block and adds it to the schema. math: createReactMathBlockSpec(), }, @@ -39,13 +40,6 @@ const insertMath = (editor: typeof schema.BlockNoteEditor) => ({ export default function App() { const editor = useCreateBlockNote({ - // Configures the syntax highlighting extension to always use LaTeX syntax highlighting in the - // Math block. - syntaxHighlighting: { - createHighlighter, - highlightBlock: (block) => - block.type === "math" ? "latex" : block.props.language, - }, schema, initialContent: [ { diff --git a/packages/code-block/src/index.test.ts b/packages/code-block/src/index.test.ts index 5eef47b172..3777d65ae0 100644 --- a/packages/code-block/src/index.test.ts +++ b/packages/code-block/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { codeBlockOptions, createHighlighter } from "./index.js"; +import { codeBlockOptions } from "./index.js"; describe("codeBlock", () => { it("should exist", () => { @@ -11,7 +11,4 @@ describe("codeBlock", () => { it("should have supportedLanguages", () => { expect(codeBlockOptions.supportedLanguages).toBeDefined(); }); - it("exports a separate createHighlighter", () => { - expect(createHighlighter).toBeDefined(); - }); }); diff --git a/packages/code-block/src/index.ts b/packages/code-block/src/index.ts index 1b5db6d952..2cb588092d 100644 --- a/packages/code-block/src/index.ts +++ b/packages/code-block/src/index.ts @@ -1,13 +1,6 @@ import type { CodeBlockOptions } from "@blocknote/core"; -import { createHighlighter as createShikiHighlighter } from "./shiki.bundle.js"; +import { createHighlighter } from "./shiki.bundle.js"; -export const createHighlighter = () => - createShikiHighlighter({ - themes: ["github-dark", "github-light"], - langs: [], - }); - -// TODO: Should this be here or in the core code block? export const codeBlockOptions = { defaultLanguage: "javascript", supportedLanguages: { @@ -204,4 +197,9 @@ export const codeBlockOptions = { aliases: ["objective-c", "objc"], }, }, + createHighlighter: () => + createHighlighter({ + themes: ["github-dark", "github-light"], + langs: [], + }), } satisfies CodeBlockOptions; diff --git a/packages/core/src/blocks/Code/CodeBlockOptions.ts b/packages/core/src/blocks/Code/CodeBlockOptions.ts index 946a55dba4..e5f4fe3c68 100644 --- a/packages/core/src/blocks/Code/CodeBlockOptions.ts +++ b/packages/core/src/blocks/Code/CodeBlockOptions.ts @@ -1,6 +1,6 @@ -// import type { ViewMutationRecord } from "prosemirror-view"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { BlockFromConfig } from "../../schema/index.js"; +import { SyntaxHighlightingOptions } from "../../extensions/index.js"; /** * Renders a preview of a code block's content (e.g. rendered LaTeX). Takes the @@ -66,7 +66,7 @@ export type CodeBlockOptions = { createPreview?: CodeBlockPreview; } >; -}; +} & Partial; export function getLanguageId( options: CodeBlockOptions, diff --git a/packages/core/src/blocks/Code/block.ts b/packages/core/src/blocks/Code/block.ts index 6c07cb0f8f..88a4edc37e 100644 --- a/packages/core/src/blocks/Code/block.ts +++ b/packages/core/src/blocks/Code/block.ts @@ -8,6 +8,7 @@ import { CodeKeyboardShortcutsExtension } from "./helpers/extensions/CodeKeyboar import { SourceBlockWithPreviewExtension } from "./helpers/extensions/SourceBlockWithPreviewExtension.js"; import { CodeBlockOptions } from "./CodeBlockOptions.js"; import { createSourceBlockWithPreview } from "./helpers/render/createSourceBlockWithPreview.js"; +import { SyntaxHighlightingExtension } from "../../extensions/index.js"; const CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY = "code-block-keyboard-shortcuts"; const CODE_BLOCK_PREVIEW_KEY = "code-block-preview"; @@ -34,6 +35,7 @@ export const createCodeBlockSpec = createBlockSpec( code: true, defining: true, isolating: false, + highlight: (block) => block.props.language, }, parse: (el) => parsePreCode(el), parseContent: (opts) => parsePreCodeContent(opts, "codeBlock"), @@ -61,6 +63,10 @@ export const createCodeBlockSpec = createBlockSpec( !!options.supportedLanguages?.[block.props.language]?.createPreview, runsBefore: [CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY], }), - ]; + options.createHighlighter && + SyntaxHighlightingExtension({ + createHighlighter: options.createHighlighter, + }), + ].filter((a) => !!a); }, ); diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 1c13f15c3b..2bd6f0b34b 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -23,7 +23,6 @@ import { ShowSelectionExtension, SideMenuExtension, SuggestionMenu, - SyntaxHighlightingExtension, TableHandlesExtension, TrailingNodeExtension, } from "../../../extensions/index.js"; @@ -180,10 +179,6 @@ export function getDefaultExtensions( ...(options.trailingBlock !== false ? [TrailingNodeExtension()] : []), ] as ExtensionFactoryInstance[]; - if (options.syntaxHighlighting) { - extensions.push(SyntaxHighlightingExtension(options.syntaxHighlighting)); - } - if ("table" in editor.schema.blockSpecs) { extensions.push(TableHandlesExtension(options)); } diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts index fab55bcbec..27238d2f8c 100644 --- a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts +++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts @@ -1,10 +1,10 @@ import type { HighlighterGeneric } from "@shikijs/types"; -import type { Block } from "../../blocks/defaultBlocks.js"; import { createExtension, ExtensionOptions, } from "../../editor/BlockNoteExtension.js"; import { lazyShikiPlugin } from "./shiki.js"; +import { LooseBlockSpec } from "../../schema/index.js"; export type SyntaxHighlightingOptions = { /** @@ -13,24 +13,9 @@ export type SyntaxHighlightingOptions = { * * When omitted, content renders without syntax highlighting. */ - createHighlighter?: () => Promise>; - /** - * Picks the language to highlight a block's content as - return the language - * key, or `undefined` to leave the block un-highlighted. This is where you - * enable highlighting for specific blocks. - * - * Defaults to the block's `language` prop (`(block) => block.props.language`), - * which covers the code block. Provide a custom function for blocks with a - * fixed language, e.g. for the math block: - * `(block) => (block.type === "math" ? "latex" : block.props.language)`. - */ - highlightBlock?: (block: Block) => string | undefined; + createHighlighter: () => Promise>; }; -/** Highlights a block as its `language` prop (covers the code block). */ -export const defaultHighlightBlock = (block: Block) => - block.props.language as string | undefined; - /** * A single editor-wide extension that syntax-highlights block content. Which * blocks get highlighted (and as which language) is decided by the @@ -41,17 +26,23 @@ export const defaultHighlightBlock = (block: Block) => */ export const SyntaxHighlightingExtension = createExtension( ({ editor, options }: ExtensionOptions) => { - const highlightBlock = options.highlightBlock ?? defaultHighlightBlock; - // Every block with inline (text) content is a candidate; `highlightBlock` // decides per-block whether and how to highlight it. - const nodeTypes = Object.values(editor.schema.blockSpecs) - .filter((blockSpec) => blockSpec.config.content === "inline") + const nodeTypes = [ + ...Object.values(editor.schema.blockSpecs), + ...Object.values(editor.schema.inlineContentSpecs), + ] + .filter( + (blockSpec): blockSpec is LooseBlockSpec => + typeof blockSpec.config === "object" && + blockSpec.config.content === "inline" && + !!blockSpec.implementation?.meta?.highlight, + ) .map((blockSpec) => blockSpec.config.type); return { key: "syntaxHighlighting", - prosemirrorPlugins: [lazyShikiPlugin(options, nodeTypes, highlightBlock)], + prosemirrorPlugins: [lazyShikiPlugin(options, nodeTypes, editor.schema)], }; }, ); diff --git a/packages/core/src/extensions/SyntaxHighlighting/shiki.ts b/packages/core/src/extensions/SyntaxHighlighting/shiki.ts index a7a9a0ffb5..96172fb83d 100644 --- a/packages/core/src/extensions/SyntaxHighlighting/shiki.ts +++ b/packages/core/src/extensions/SyntaxHighlighting/shiki.ts @@ -1,8 +1,8 @@ import type { HighlighterGeneric } from "@shikijs/types"; import { Parser, createHighlightPlugin } from "prosemirror-highlight"; import { createParser } from "prosemirror-highlight/shiki"; -import type { Block } from "../../blocks/defaultBlocks.js"; import type { SyntaxHighlightingOptions } from "./SyntaxHighlighting.js"; +import { CustomBlockNoteSchema } from "../../schema/schema.js"; export const shikiParserSymbol = Symbol.for("blocknote.shikiParser"); export const shikiHighlighterPromiseSymbol = Symbol.for( @@ -24,7 +24,7 @@ const PLAIN_TEXT_LANGUAGES = ["text", "none", "plaintext", "txt"]; export function lazyShikiPlugin( options: SyntaxHighlightingOptions, nodeTypes: string[], - highlightBlock: (block: Block) => string | undefined, + schema: CustomBlockNoteSchema, ) { const globalThisForShiki = globalThis as { [shikiHighlighterPromiseSymbol]?: Promise>; @@ -84,11 +84,18 @@ export function lazyShikiPlugin( // The highlight plugin only gives us the block content node, so we can only // reconstruct the block's `type` and `props` (which is all `highlightBlock` // needs to pick a language). - languageExtractor: (node) => - highlightBlock({ + languageExtractor: (node) => { + const nodeShape = { type: node.type.name, props: node.attrs, - } as Block), + }; + // search for the node in the blockSpec or inlineContentSpecs + const spec = + schema.blockSpecs[nodeShape.type] || + schema.inlineContentSpecs[nodeShape.type]; + + return spec?.implementation?.meta?.highlight?.(nodeShape) ?? undefined; + }, nodeTypes, }); } diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 18b0404baa..0690496a05 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -31,7 +31,10 @@ export type BlockNoteDOMAttributes = Partial<{ [DOMElement in BlockNoteDOMElement]: Record; }>; -export interface BlockConfigMeta { +export interface BlockConfigMeta< + TName extends string = string, + TProps extends PropSchema = PropSchema, +> { /** * Defines which keyboard shortcut should be used to insert a hard break into the block's inline content. * @default "shift+enter" @@ -62,6 +65,11 @@ export interface BlockConfigMeta { * Whether the block is a {@link https://prosemirror.net/docs/ref/#model.NodeSpec.isolating} block */ isolating?: boolean; + + /** + * Enables syntax highlighting of the contents of the block with the result of this callback + */ + highlight?(block: { type: TName; props: Props }): string | undefined; } /** @@ -483,7 +491,7 @@ export type BlockImplementation< /** * Metadata */ - meta?: BlockConfigMeta; + meta?: BlockConfigMeta; /** * A function that converts the block into a DOM element */ diff --git a/packages/core/src/schema/inlineContent/internal.ts b/packages/core/src/schema/inlineContent/internal.ts index 9d10c7cb4e..9bcbfed638 100644 --- a/packages/core/src/schema/inlineContent/internal.ts +++ b/packages/core/src/schema/inlineContent/internal.ts @@ -101,10 +101,18 @@ export function createInlineContentSpecFromTipTapNode< propSchema, content: node.config.content === "inline*" ? "styled" : "none", }, + // Cast needed because `implementation` is typed against the generic + // `CustomInlineContentConfig`, while `createInternalInlineContentSpec` + // expects the implementation for the specific (still-generic) config + // inferred from `node`/`propSchema` above. { ...implementation, node, - }, + } as unknown as InlineContentImplementation<{ + type: T["name"]; + propSchema: P; + content: "styled" | "none"; + }>, ); } diff --git a/packages/core/src/schema/inlineContent/types.ts b/packages/core/src/schema/inlineContent/types.ts index b8e922502a..8fc48687de 100644 --- a/packages/core/src/schema/inlineContent/types.ts +++ b/packages/core/src/schema/inlineContent/types.ts @@ -16,10 +16,28 @@ export type InlineContentConfig = CustomInlineContentConfig | "text" | "link"; // InlineContentImplementation contains the "implementation" info about an InlineContent element // such as the functions / Nodes required to render and / or serialize it export type InlineContentImplementation = - T extends "link" | "text" - ? undefined - : { + T extends CustomInlineContentConfig + ? { meta?: { + /** + * Whether the inline content is a {@link https://prosemirror.net/docs/ref/#model.NodeSpec.code} block + */ + code?: boolean; + /** + * When {@link code} is `true`, this can syntax highlight the contents of the block with the result of this callback + */ + // Method syntax (rather than an arrow-function property) so its + // parameter is checked bivariantly, keeping a specific + // implementation assignable to the generic spec record type. + highlight?( + inlineContent: Pick< + CustomInlineContentFromConfig, + "type" | "props" + >, + ): string | undefined; + /** + * Whether the inline content is draggable + */ draggable?: boolean; }; node: Node; @@ -43,7 +61,8 @@ export type InlineContentImplementation = destroy?: () => void; }; runsBefore?: string[]; - }; + } + : undefined; export type InlineContentSchemaWithInlineContent< IType extends string, diff --git a/packages/math-block/src/block.ts b/packages/math-block/src/block.ts index 9cdd1fcccb..1ff49eed8e 100644 --- a/packages/math-block/src/block.ts +++ b/packages/math-block/src/block.ts @@ -3,6 +3,8 @@ import { createBlockSpec, createSourceBlockWithPreview, SourceBlockWithPreviewExtension, + SyntaxHighlightingExtension, + SyntaxHighlightingOptions, } from "@blocknote/core"; import { parseMathML, @@ -16,7 +18,7 @@ const MATH_BLOCK_PREVIEW_KEY = "math-block-preview"; export type MathBlockConfig = ReturnType; export const createMathBlockConfig = createBlockConfig( - () => + (_options: Partial) => ({ type: "math" as const, propSchema: {}, @@ -31,6 +33,7 @@ export const createMathBlockSpec = createBlockSpec( code: true, defining: true, isolating: false, + highlight: () => "latex", }, parse: (el) => parseMathML(el), parseContent: ({ el, schema }) => parseMathMLContent({ el, schema }), @@ -40,12 +43,18 @@ export const createMathBlockSpec = createBlockSpec( }), toExternalHTML: (block) => createMathML(block), }, - [ - // Math blocks always render a preview. - SourceBlockWithPreviewExtension({ - key: MATH_BLOCK_PREVIEW_KEY, - blockType: "math", - hasPreview: () => true, - }), - ], + (options) => + [ + // Math blocks always render a preview. + SourceBlockWithPreviewExtension({ + key: MATH_BLOCK_PREVIEW_KEY, + blockType: "math", + hasPreview: () => true, + }), + options.createHighlighter + ? SyntaxHighlightingExtension({ + createHighlighter: options.createHighlighter, + }) + : undefined, + ].filter((a) => !!a), ); diff --git a/packages/math-block/src/reactBlock.tsx b/packages/math-block/src/reactBlock.tsx index 11627a729f..0db19c7652 100644 --- a/packages/math-block/src/reactBlock.tsx +++ b/packages/math-block/src/reactBlock.tsx @@ -20,6 +20,7 @@ export const createReactMathBlockSpec = createReactBlockSpec( code: true, defining: true, isolating: false, + highlight: () => "latex", }, parse: (el) => parseMathML(el), parseContent: ({ el, schema }) => parseMathMLContent({ el, schema }), From 62013eb49265ce112366ab4c0c073c7b47e5c3f0 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 9 Jul 2026 15:08:05 +0200 Subject: [PATCH 3/4] refactor: make syntax highlighting a user-provided extension; error on duplicate keys Syntax highlighting is now a standalone extension the user adds to the editor's `extensions`, rather than config wired into each block. Removes `createHighlighter` from `CodeBlockOptions` and the math block options, and removes the dead editor-level `syntaxHighlighting` option. Blocks keep declaring their language via `meta.highlight`, so the extension highlights whichever blocks are present when it's added; when it's absent, they render as plain text. `@blocknote/code-block` now exports a pre-configured `syntaxHighlighter` extension (bundled Shiki + github themes); custom highlighters use the `SyntaxHighlightingExtension` factory from `@blocknote/core`. Also makes the ExtensionManager throw on any duplicate extension key instead of silently de-duplicating ("first wins"). A shared extension must be registered once rather than by each consumer. This is safe because the only real duplicate-key case (code + math both registering the highlighter) is removed by this refactor; the sole `blockNoteExtensions` user (CollaborationExtension) has unique sub-extension keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../docs/features/blocks/code-blocks.mdx | 39 +++--- examples/04-theming/06-code-block/src/App.tsx | 8 +- .../07-custom-code-block/src/App.tsx | 29 +++-- .../09-math-block/src/App.tsx | 5 +- packages/code-block/src/index.test.ts | 8 +- packages/code-block/src/index.ts | 26 +++- .../core/src/blocks/Code/CodeBlockOptions.ts | 3 +- packages/core/src/blocks/Code/block.ts | 33 ++--- packages/core/src/editor/BlockNoteEditor.ts | 8 -- .../ExtensionManager/ExtensionManager.test.ts | 123 +++++------------- .../editor/managers/ExtensionManager/index.ts | 29 +++-- .../SyntaxHighlighting.test.ts | 11 +- .../SyntaxHighlighting/SyntaxHighlighting.ts | 14 +- .../extensions/SyntaxHighlighting/shiki.ts | 10 +- packages/math-block/src/block.ts | 26 ++-- 15 files changed, 168 insertions(+), 204 deletions(-) diff --git a/docs/content/docs/features/blocks/code-blocks.mdx b/docs/content/docs/features/blocks/code-blocks.mdx index 5a94af0498..8dc4ce4610 100644 --- a/docs/content/docs/features/blocks/code-blocks.mdx +++ b/docs/content/docs/features/blocks/code-blocks.mdx @@ -45,30 +45,28 @@ type CodeBlockOptions = { **Syntax Highlighting** -Syntax highlighting is handled by a separate editor extension, configured at the editor level via the `syntaxHighlighting` option (not on the code block itself), so it can highlight any block's content: +Syntax highlighting is handled by a separate editor extension that you add to the editor's `extensions` (not configured on the code block itself), so it can highlight any block that declares a language — the code block, and blocks like the math block. When the extension isn't added, those blocks render as plain text. + +The extension is configured with a Shiki highlighter: ```ts type SyntaxHighlightingOptions = { - createHighlighter?: () => Promise>; - highlightBlock?: (block: { - type: string; - props: Record; - }) => string | undefined; + createHighlighter: () => Promise>; }; ``` `createHighlighter:` The [Shiki highlighter](https://shiki.style/guide/load-theme) to use for syntax highlighting. -`highlightBlock:` Picks the language to highlight a block's content as (return the language key, or `undefined` to leave it un-highlighted). This is how you enable highlighting for specific blocks. Defaults to the block's `language` prop (`(block) => block.props.language`), which covers the code block. For a block with a fixed language, return it directly — e.g. for a math block: `(block) => (block.type === "math" ? "latex" : block.props.language)`. +Which blocks get highlighted (and as which language) is decided by each block's spec via its `meta.highlight` callback — the code block highlights as its `language` prop, the math block always as `latex` — so you don't configure this on the extension. -BlockNote provides a generic, ready-to-use set of these in the `@blocknote/code-block` package, which supports a wide range of languages. The code block options and the highlighter are exported separately: +BlockNote provides a generic, ready-to-use highlighter in the `@blocknote/code-block` package, which supports a wide range of languages. It's exported as a pre-configured `syntaxHighlighter` extension, alongside the code block options: ```ts import { createCodeBlockSpec } from "@blocknote/core"; -import { codeBlockOptions, createHighlighter } from "@blocknote/code-block"; +import { codeBlockOptions, syntaxHighlighter } from "@blocknote/code-block"; const editor = useCreateBlockNote({ - syntaxHighlighting: { createHighlighter }, + extensions: [syntaxHighlighter], schema: BlockNoteSchema.create().extend({ blockSpecs: { codeBlock: createCodeBlockSpec(codeBlockOptions), @@ -110,19 +108,22 @@ This will generate a `shiki.bundle.ts` file that you can use to create a syntax Like this: ```ts +import { SyntaxHighlightingExtension } from "@blocknote/core"; import { createHighlighter } from "./shiki.bundle.js"; +// Build a syntax highlighter extension from your custom Shiki bundle, then add +// it to the editor's `extensions`. +const syntaxHighlighter = SyntaxHighlightingExtension({ + createHighlighter: () => + createHighlighter({ + themes: ["light-plus", "dark-plus"], + langs: [], + }), +}); + export default function App() { const editor = useCreateBlockNote({ - // The highlighter is configured at the editor level, separately from the - // code block's own options. - syntaxHighlighting: { - createHighlighter: () => - createHighlighter({ - themes: ["light-plus", "dark-plus"], - langs: [], - }), - }, + extensions: [syntaxHighlighter], schema: BlockNoteSchema.create().extend({ blockSpecs: { codeBlock: createCodeBlockSpec({ diff --git a/examples/04-theming/06-code-block/src/App.tsx b/examples/04-theming/06-code-block/src/App.tsx index 82d10bae9e..b5b3fbbd3a 100644 --- a/examples/04-theming/06-code-block/src/App.tsx +++ b/examples/04-theming/06-code-block/src/App.tsx @@ -3,12 +3,16 @@ import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; -// This packages some of the most used languages in on-demand bundle -import { codeBlockOptions } from "@blocknote/code-block"; +// This packages some of the most used languages in on-demand bundle, and a +// ready-to-use syntax highlighter extension configured with them. +import { codeBlockOptions, syntaxHighlighter } from "@blocknote/code-block"; export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ + // Adding the syntax highlighter extension enables syntax highlighting for + // the code block. Without it, code renders as plain text. + extensions: [syntaxHighlighter], schema: BlockNoteSchema.create().extend({ blockSpecs: { codeBlock: createCodeBlockSpec(codeBlockOptions), diff --git a/examples/04-theming/07-custom-code-block/src/App.tsx b/examples/04-theming/07-custom-code-block/src/App.tsx index dbeb84b367..387d3463c8 100644 --- a/examples/04-theming/07-custom-code-block/src/App.tsx +++ b/examples/04-theming/07-custom-code-block/src/App.tsx @@ -1,4 +1,8 @@ -import { BlockNoteSchema, createCodeBlockSpec } from "@blocknote/core"; +import { + BlockNoteSchema, + createCodeBlockSpec, + SyntaxHighlightingExtension, +} from "@blocknote/core"; import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; @@ -6,19 +10,22 @@ import { useCreateBlockNote } from "@blocknote/react"; // Bundle created from `npx shiki-codegen --langs typescript,javascript,react --themes light-plus,dark-plus --engine javascript --precompiled ./shiki.bundle.ts` import { createHighlighter } from "./shiki.bundle"; +// Syntax highlighting is a separate extension, configured with a highlighter. +// Here we build one from our own custom Shiki bundle (with `dark-plus` / +// `light-plus` themes) and pass it to the editor's `extensions` below. +const syntaxHighlighter = SyntaxHighlightingExtension({ + // This creates a highlighter, it can be asynchronous to load it afterwards + createHighlighter: () => + createHighlighter({ + themes: ["dark-plus", "light-plus"], + langs: [], + }), +}); + export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ - // The Shiki highlighter is configured at the editor level, separately from - // the code block's own options (default language & language menu). - syntaxHighlighting: { - // This creates a highlighter, it can be asynchronous to load it afterwards - createHighlighter: () => - createHighlighter({ - themes: ["dark-plus", "light-plus"], - langs: [], - }), - }, + extensions: [syntaxHighlighter], schema: BlockNoteSchema.create().extend({ blockSpecs: { codeBlock: createCodeBlockSpec({ diff --git a/examples/06-custom-schema/09-math-block/src/App.tsx b/examples/06-custom-schema/09-math-block/src/App.tsx index 6007d735d8..1b930b4bf0 100644 --- a/examples/06-custom-schema/09-math-block/src/App.tsx +++ b/examples/06-custom-schema/09-math-block/src/App.tsx @@ -4,7 +4,7 @@ import { filterSuggestionItems, insertOrUpdateBlockForSlashMenu, } from "@blocknote/core/extensions"; -import { codeBlockOptions } from "@blocknote/code-block"; +import { codeBlockOptions, syntaxHighlighter } from "@blocknote/code-block"; import { createReactMathBlockSpec } from "@blocknote/math-block"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; @@ -40,6 +40,9 @@ const insertMath = (editor: typeof schema.BlockNoteEditor) => ({ export default function App() { const editor = useCreateBlockNote({ + // The syntax highlighter extension highlights both the code block and the + // LaTeX source of math blocks. Without it, they render as plain text. + extensions: [syntaxHighlighter], schema, initialContent: [ { diff --git a/packages/code-block/src/index.test.ts b/packages/code-block/src/index.test.ts index 3777d65ae0..8a83d858b5 100644 --- a/packages/code-block/src/index.test.ts +++ b/packages/code-block/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { codeBlockOptions } from "./index.js"; +import { codeBlockOptions, syntaxHighlighter } from "./index.js"; describe("codeBlock", () => { it("should exist", () => { @@ -11,4 +11,10 @@ describe("codeBlock", () => { it("should have supportedLanguages", () => { expect(codeBlockOptions.supportedLanguages).toBeDefined(); }); + it("should not configure a highlighter (that's now the syntaxHighlighter extension)", () => { + expect("createHighlighter" in codeBlockOptions).toBe(false); + }); + it("should export a pre-configured syntaxHighlighter extension", () => { + expect(syntaxHighlighter).toBeDefined(); + }); }); diff --git a/packages/code-block/src/index.ts b/packages/code-block/src/index.ts index 2cb588092d..7cc905f662 100644 --- a/packages/code-block/src/index.ts +++ b/packages/code-block/src/index.ts @@ -1,6 +1,27 @@ import type { CodeBlockOptions } from "@blocknote/core"; +import { SyntaxHighlightingExtension } from "@blocknote/core"; import { createHighlighter } from "./shiki.bundle.js"; +/** + * A ready-to-use syntax highlighting extension, pre-configured with this + * package's bundled Shiki highlighter (the languages in `codeBlockOptions` and + * the `github-dark` / `github-light` themes). Add it to the editor's + * `extensions` to enable syntax highlighting for code blocks (and any other + * block that declares a language, such as the math block): + * + * @example + * ```ts + * useCreateBlockNote({ extensions: [syntaxHighlighter] }); + * ``` + */ +export const syntaxHighlighter = SyntaxHighlightingExtension({ + createHighlighter: () => + createHighlighter({ + themes: ["github-dark", "github-light"], + langs: [], + }), +}); + export const codeBlockOptions = { defaultLanguage: "javascript", supportedLanguages: { @@ -197,9 +218,4 @@ export const codeBlockOptions = { aliases: ["objective-c", "objc"], }, }, - createHighlighter: () => - createHighlighter({ - themes: ["github-dark", "github-light"], - langs: [], - }), } satisfies CodeBlockOptions; diff --git a/packages/core/src/blocks/Code/CodeBlockOptions.ts b/packages/core/src/blocks/Code/CodeBlockOptions.ts index e5f4fe3c68..5f9435b0c4 100644 --- a/packages/core/src/blocks/Code/CodeBlockOptions.ts +++ b/packages/core/src/blocks/Code/CodeBlockOptions.ts @@ -1,6 +1,5 @@ import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { BlockFromConfig } from "../../schema/index.js"; -import { SyntaxHighlightingOptions } from "../../extensions/index.js"; /** * Renders a preview of a code block's content (e.g. rendered LaTeX). Takes the @@ -66,7 +65,7 @@ export type CodeBlockOptions = { createPreview?: CodeBlockPreview; } >; -} & Partial; +}; export function getLanguageId( options: CodeBlockOptions, diff --git a/packages/core/src/blocks/Code/block.ts b/packages/core/src/blocks/Code/block.ts index 88a4edc37e..6e0d5441ab 100644 --- a/packages/core/src/blocks/Code/block.ts +++ b/packages/core/src/blocks/Code/block.ts @@ -8,7 +8,6 @@ import { CodeKeyboardShortcutsExtension } from "./helpers/extensions/CodeKeyboar import { SourceBlockWithPreviewExtension } from "./helpers/extensions/SourceBlockWithPreviewExtension.js"; import { CodeBlockOptions } from "./CodeBlockOptions.js"; import { createSourceBlockWithPreview } from "./helpers/render/createSourceBlockWithPreview.js"; -import { SyntaxHighlightingExtension } from "../../extensions/index.js"; const CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY = "code-block-keyboard-shortcuts"; const CODE_BLOCK_PREVIEW_KEY = "code-block-preview"; @@ -50,23 +49,17 @@ export const createCodeBlockSpec = createBlockSpec( ), toExternalHTML: (block) => createPreCode(block), }), - (options) => { - return [ - CodeKeyboardShortcutsExtension(options)( - CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY, - "codeBlock", - ), - SourceBlockWithPreviewExtension({ - key: CODE_BLOCK_PREVIEW_KEY, - blockType: "codeBlock", - hasPreview: (block) => - !!options.supportedLanguages?.[block.props.language]?.createPreview, - runsBefore: [CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY], - }), - options.createHighlighter && - SyntaxHighlightingExtension({ - createHighlighter: options.createHighlighter, - }), - ].filter((a) => !!a); - }, + (options) => [ + CodeKeyboardShortcutsExtension(options)( + CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY, + "codeBlock", + ), + SourceBlockWithPreviewExtension({ + key: CODE_BLOCK_PREVIEW_KEY, + blockType: "codeBlock", + hasPreview: (block) => + !!options.supportedLanguages?.[block.props.language]?.createPreview, + runsBefore: [CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY], + }), + ], ); diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 9395c8ef70..13d65ad83d 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -17,7 +17,6 @@ import { DefaultStyleSchema, PartialBlock, } from "../blocks/index.js"; -import type { SyntaxHighlightingOptions } from "../extensions/SyntaxHighlighting/SyntaxHighlighting.js"; import { BlockChangeExtension, DropCursorOptions, @@ -254,13 +253,6 @@ export interface BlockNoteEditorOptions< */ setIdAttribute?: boolean; - /** - * Options for syntax highlighting block content: the Shiki highlighter to use, - * and a `highlightBlock` function picking which blocks to highlight and as - * which language. - */ - syntaxHighlighting?: SyntaxHighlightingOptions; - /** * Determines behavior when pressing Tab (or Shift-Tab) while multiple blocks are selected and a toolbar is open. * - `"prefer-navigate-ui"`: Changes focus to the toolbar. User must press Escape to close toolbar before indenting blocks. Better for keyboard accessibility. diff --git a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts index 4c97e8849c..7301e37520 100644 --- a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts +++ b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts @@ -28,42 +28,25 @@ function pluginIndex( ); } -describe("ExtensionManager de-duplication by key", () => { - it("registers only the first extension when two share a key", () => { - let mountCount = 0; - +describe("ExtensionManager unique keys", () => { + it("throws when two extensions are registered directly with the same key", () => { const first = createExtension(() => ({ key: "dup", value: "first", - mount() { - mountCount++; - return () => {}; - }, })); const second = createExtension(() => ({ key: "dup", value: "second", - mount() { - mountCount++; - return () => {}; - }, - })); - - const editor = createMountedEditor([first(), second()]); - - // The first registration wins. - expect(editor.getExtension(first)?.value).toBe("first"); - // The second registration was skipped entirely. - expect(editor.getExtension(second)).toBeUndefined(); - expect((editor.extensions.get("dup") as any)?.value).toBe("first"); - expect( - [...editor.extensions.values()].filter((e) => e.key === "dup").length, - ).toBe(1); - // Only the registered extension was mounted. - expect(mountCount).toBe(1); + })); + + // Two registrations of the same key are a configuration error and must fail + // loudly rather than silently dropping the second. + expect(() => createMountedEditor([first(), second()])).toThrow( + /already registered/, + ); }); - it("does not re-register a dependency declared via blockNoteExtensions when it is already registered", () => { + it("throws when a blockNoteExtensions dependency reuses an already-registered key", () => { // Two distinct factories sharing the key "dep". const depDirect = createExtension(() => ({ key: "dep", @@ -78,16 +61,31 @@ describe("ExtensionManager de-duplication by key", () => { blockNoteExtensions: [depFromParent()], })); - // Register the dependency directly first, then a parent that also pulls in - // its own "dep" via blockNoteExtensions. - const editor = createMountedEditor([depDirect(), parent()]); + // Registering the dependency directly and also declaring a (distinct) + // extension with the same key via blockNoteExtensions is a duplicate - keys + // must be unique regardless of how the extension is registered. + expect(() => createMountedEditor([depDirect(), parent()])).toThrow( + /already registered/, + ); + }); - expect(editor.getExtension(parent)).toBeDefined(); - // The directly-registered dependency wins; the one declared by the parent - // is skipped rather than overriding it. - expect(editor.getExtension(depDirect)?.value).toBe("direct"); - expect(editor.getExtension(depFromParent)).toBeUndefined(); - expect((editor.extensions.get("dep") as any)?.value).toBe("direct"); + it("throws when a shared sub-dependency is declared by two parents", () => { + // A single sub-extension factory declared by two different parents produces + // two distinct instances with the same key - a duplicate. Shared extensions + // must be registered once, not depended upon by multiple parents. + const sharedSub = createExtension(() => ({ key: "shared-sub" })); + const parentA = createExtension(() => ({ + key: "parent-a", + blockNoteExtensions: [sharedSub()], + })); + const parentB = createExtension(() => ({ + key: "parent-b", + blockNoteExtensions: [sharedSub()], + })); + + expect(() => createMountedEditor([parentA(), parentB()])).toThrow( + /already registered/, + ); }); it("registers a dependency declared via blockNoteExtensions when it isn't registered otherwise", () => { @@ -193,57 +191,4 @@ describe("ExtensionManager ordering", () => { pluginIndex(editor, parentKey), ); }); - - it("runs a shared sub-dependency before both extensions that declare it", () => { - const subKey = new PluginKey("shared-sub"); - const parentAKey = new PluginKey("shared-parent-a"); - const parentBKey = new PluginKey("shared-parent-b"); - const otherKey = new PluginKey("shared-other"); - - const other = createExtension(() => ({ - key: "shared-other", - prosemirrorPlugins: [new Plugin({ key: otherKey })], - })); - // A single sub-extension instance declared by two different parents. It is - // registered once (de-duplicated) and must run before both parents. - const sharedSub = createExtension(() => ({ - key: "shared-sub", - prosemirrorPlugins: [new Plugin({ key: subKey })], - })); - const parentA = createExtension(() => ({ - key: "shared-parent-a", - blockNoteExtensions: [sharedSub()], - prosemirrorPlugins: [new Plugin({ key: parentAKey })], - })); - // parentB declares the *already-registered* sub (so its registration is - // de-duplicated) and has a higher base priority via runsBefore. The - // dependency must still be recorded on the de-duplicated path so the sub - // runs before parentB too. - const parentB = createExtension(() => ({ - key: "shared-parent-b", - runsBefore: ["shared-other"], - blockNoteExtensions: [sharedSub()], - prosemirrorPlugins: [new Plugin({ key: parentBKey })], - })); - - const editor = createMountedEditor([parentA(), parentB(), other()]); - - // The sub is registered exactly once despite being declared twice. - expect( - [...editor.extensions.values()].filter((e) => e.key === "shared-sub") - .length, - ).toBe(1); - - // parentB's higher base priority puts it before the unrelated extension... - expect(pluginIndex(editor, parentBKey)).toBeLessThan( - pluginIndex(editor, otherKey), - ); - // ...but the shared sub still runs before both parents. - expect(pluginIndex(editor, subKey)).toBeLessThan( - pluginIndex(editor, parentAKey), - ); - expect(pluginIndex(editor, subKey)).toBeLessThan( - pluginIndex(editor, parentBKey), - ); - }); }); diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index 17d1b46083..39bcaf76d4 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -203,11 +203,23 @@ export class ExtensionManager { return undefined as any; } + // Extension keys must be unique. Registering a second extension with a key + // that's already taken - whether directly or as a dependency declared via + // another extension's `blockNoteExtensions` - is a configuration error: the + // duplicate would otherwise be silently dropped, hiding the bug (and leaving + // it ambiguous which instance's configuration wins). We fail loudly instead. + // A shared extension should be registered exactly once, not registered again + // by each consumer. + if (this.extensions.some((e) => e.key === instance.key)) { + throw new Error( + `An extension with key "${instance.key}" is already registered. ` + + `Extension keys must be unique - register the shared extension once ` + + `rather than registering it again.`, + ); + } + // A sub-extension declared via `blockNoteExtensions` must run before the - // extension that declares it. We record this dependency before the - // de-duplication check below, so that it applies even when multiple - // extensions declare the same sub-extension (and all but the first are - // de-duplicated). + // extension that declares it, so record that ordering edge. if (parentKey) { let dependents = this.blockNoteExtensionDependents.get(instance.key); if (!dependents) { @@ -217,15 +229,6 @@ export class ExtensionManager { dependents.add(parentKey); } - // De-duplicate by key: if an extension with the same key is already - // registered, don't register it again. This allows an extension to declare - // a dependency on another extension via `blockNoteExtensions` without - // conflicting when the user (or another extension) registers that same - // extension directly. The first registration wins. - if (this.extensions.some((e) => e.key === instance.key)) { - return undefined as any; - } - // Now that we know that the extension is not disabled, we can add it to the extension factories if (typeof extension === "function") { const originalFactory = (instance as any)[originalFactorySymbol] as ( diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts index 36fd862b3a..8224149108 100644 --- a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts +++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts @@ -6,8 +6,8 @@ import { SyntaxHighlightingExtension } from "./SyntaxHighlighting.js"; */ describe("SyntaxHighlightingExtension", () => { - // The extension only reads `editor.schema.blockSpecs`, so a minimal stub is - // enough. + // The extension only reads `editor.schema.blockSpecs` and + // `inlineContentSpecs`, so a minimal stub is enough. const fakeEditor = () => ({ schema: { @@ -16,6 +16,7 @@ describe("SyntaxHighlightingExtension", () => { codeBlock: { config: { type: "codeBlock", content: "inline" } }, image: { config: { type: "image", content: "none" } }, }, + inlineContentSpecs: {}, }, }) as any; @@ -23,9 +24,9 @@ describe("SyntaxHighlightingExtension", () => { SyntaxHighlightingExtension(options)({ editor: fakeEditor() }) .prosemirrorPlugins; - // Whether highlighting is enabled at all is decided by the editor (it only - // instantiates this extension when the `syntaxHighlighting` option is set), so - // the extension itself always installs the plugin once created. + // Whether highlighting is enabled at all is decided by the user (they choose + // to add this extension to the editor's `extensions`), so the extension + // itself always installs the plugin once created. it("installs a highlight plugin when a highlighter is configured", () => { const plugins = pluginsFor({ createHighlighter: async () => ({}) as any }); diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts index 27238d2f8c..7c7cff0c8d 100644 --- a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts +++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts @@ -18,16 +18,18 @@ export type SyntaxHighlightingOptions = { /** * A single editor-wide extension that syntax-highlights block content. Which - * blocks get highlighted (and as which language) is decided by the - * `highlightBlock` option, so individual blocks don't configure it themselves. + * blocks get highlighted (and as which language) is decided by each spec's + * `meta.highlight` callback, so individual blocks declare their own language + * rather than the extension configuring them. * - * Highlighting is opt-in: this extension is only instantiated when the - * `syntaxHighlighting` option is configured (see `getDefaultExtensions`). + * Highlighting is opt-in: the user adds this extension to the editor's + * `extensions` (configured with a `createHighlighter`) to enable it. When it's + * absent, blocks render as plain text. */ export const SyntaxHighlightingExtension = createExtension( ({ editor, options }: ExtensionOptions) => { - // Every block with inline (text) content is a candidate; `highlightBlock` - // decides per-block whether and how to highlight it. + // Every block/inline-content spec with a `meta.highlight` callback is a + // candidate; that callback decides the language to highlight it as. const nodeTypes = [ ...Object.values(editor.schema.blockSpecs), ...Object.values(editor.schema.inlineContentSpecs), diff --git a/packages/core/src/extensions/SyntaxHighlighting/shiki.ts b/packages/core/src/extensions/SyntaxHighlighting/shiki.ts index 96172fb83d..3e7f1764c6 100644 --- a/packages/core/src/extensions/SyntaxHighlighting/shiki.ts +++ b/packages/core/src/extensions/SyntaxHighlighting/shiki.ts @@ -17,9 +17,9 @@ const PLAIN_TEXT_LANGUAGES = ["text", "none", "plaintext", "txt"]; * Creates the syntax highlighting plugin for the given block types, lazily * loading the highlighter on first use. * - * `highlightBlock` resolves each block to a language, which is passed straight - * to Shiki - it resolves aliases and loads the grammar from its bundle, so any - * language the provided highlighter bundles can be highlighted. + * Each spec's `meta.highlight` callback resolves a node to a language, which is + * passed straight to Shiki - it resolves aliases and loads the grammar from its + * bundle, so any language the provided highlighter bundles can be highlighted. */ export function lazyShikiPlugin( options: SyntaxHighlightingOptions, @@ -82,8 +82,8 @@ export function lazyShikiPlugin( return createHighlightPlugin({ parser: lazyParser, // The highlight plugin only gives us the block content node, so we can only - // reconstruct the block's `type` and `props` (which is all `highlightBlock` - // needs to pick a language). + // reconstruct the block's `type` and `props` (which is all a spec's + // `meta.highlight` needs to pick a language). languageExtractor: (node) => { const nodeShape = { type: node.type.name, diff --git a/packages/math-block/src/block.ts b/packages/math-block/src/block.ts index 1ff49eed8e..dff8bdc155 100644 --- a/packages/math-block/src/block.ts +++ b/packages/math-block/src/block.ts @@ -3,8 +3,6 @@ import { createBlockSpec, createSourceBlockWithPreview, SourceBlockWithPreviewExtension, - SyntaxHighlightingExtension, - SyntaxHighlightingOptions, } from "@blocknote/core"; import { parseMathML, @@ -18,7 +16,7 @@ const MATH_BLOCK_PREVIEW_KEY = "math-block-preview"; export type MathBlockConfig = ReturnType; export const createMathBlockConfig = createBlockConfig( - (_options: Partial) => + () => ({ type: "math" as const, propSchema: {}, @@ -43,18 +41,12 @@ export const createMathBlockSpec = createBlockSpec( }), toExternalHTML: (block) => createMathML(block), }, - (options) => - [ - // Math blocks always render a preview. - SourceBlockWithPreviewExtension({ - key: MATH_BLOCK_PREVIEW_KEY, - blockType: "math", - hasPreview: () => true, - }), - options.createHighlighter - ? SyntaxHighlightingExtension({ - createHighlighter: options.createHighlighter, - }) - : undefined, - ].filter((a) => !!a), + () => [ + // Math blocks always render a preview. + SourceBlockWithPreviewExtension({ + key: MATH_BLOCK_PREVIEW_KEY, + blockType: "math", + hasPreview: () => true, + }), + ], ); From 042a4b17ef1044c2a1cb2aa0b8f5b4ce866f6176 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 9 Jul 2026 18:03:04 +0200 Subject: [PATCH 4/4] refactor: make SourceBlockWithPreview a singleton; add inline syntax highlighting Refactor SourceBlockWithPreviewExtension and its inline sibling into single editor-wide extensions, mirroring SyntaxHighlightingExtension: - Register both once as default extensions instead of per block/inline spec. - Blocks/inline content opt in via a `meta.hasPreview` flag rather than the extension taking a `blockType`/`inlineContentType`; drop `key`, `runsBefore`, the always-true `hasPreview` callback, and the `enterBehaviour` option. - Multi-line preview blocks (diagram) declare `meta.hardBreakShortcut: "enter"`; the extension reads it from the spec to decide Enter behavior. Note that the shared KeyboardShortcutsExtension reads hardBreakShortcut off the block config, which carries no meta - flagged with a comment for a follow-up. Enable syntax highlighting for inline content (e.g. inline math): - collectHighlightNodeTypes now also collects inline-content specs with a `meta.highlight`; add `highlight`/`hasPreview` to the inline meta types. - Inline math declares `highlight: () => "latex"`. - Bump prosemirror-highlight to 0.15.3, which collects nodes by `node.inlineContent` (ocavue/prosemirror-highlight#137) so inline nodes are highlighted alongside text blocks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../10-diagram-block/src/App.tsx | 12 +- packages/core/package.json | 2 +- .../managers/ExtensionManager/extensions.ts | 4 + .../SourceBlockWithPreview.ts | 64 ++++----- .../SourceInlineContentWithPreview.ts | 42 +++--- .../SyntaxHighlighting.test.ts | 83 ++++++++++- .../SyntaxHighlighting/SyntaxHighlighting.ts | 76 +++++++--- .../inlineHighlight.test.ts | 135 ++++++++++++++++++ .../KeyboardShortcutsExtension.ts | 8 ++ packages/core/src/schema/blocks/types.ts | 7 + .../src/schema/inlineContent/createSpec.ts | 16 +++ .../core/src/schema/inlineContent/types.ts | 6 + .../src/block/createReactDiagramBlockSpec.tsx | 30 ++-- .../block/createReactMathBlockSpec.test.tsx | 17 +++ .../src/block/createReactMathBlockSpec.tsx | 20 +-- .../createReactMathInlineContentSpec.tsx | 18 +-- pnpm-lock.yaml | 10 +- 17 files changed, 418 insertions(+), 132 deletions(-) create mode 100644 packages/core/src/extensions/SyntaxHighlighting/inlineHighlight.test.ts diff --git a/examples/06-custom-schema/10-diagram-block/src/App.tsx b/examples/06-custom-schema/10-diagram-block/src/App.tsx index 4c74ece702..87a8b01d27 100644 --- a/examples/06-custom-schema/10-diagram-block/src/App.tsx +++ b/examples/06-custom-schema/10-diagram-block/src/App.tsx @@ -1,4 +1,4 @@ -import { createHighlighter } from "@blocknote/code-block"; +import { syntaxHighlighter } from "@blocknote/code-block"; import { BlockNoteSchema } from "@blocknote/core"; import { filterSuggestionItems, @@ -42,13 +42,9 @@ const insertDiagram = (editor: typeof schema.BlockNoteEditor) => ({ export default function App() { const editor = useCreateBlockNote({ - // Configures the syntax highlighting extension to use Mermaid syntax - // highlighting in the Diagram block's source popup. - syntaxHighlighting: { - createHighlighter, - highlightBlock: (block) => - block.type === "diagram" ? "mermaid" : block.props.language, - }, + // The syntax highlighter extension highlights the Diagram block's Mermaid + // source in its popup (the block declares `highlight: () => "mermaid"`). + extensions: [syntaxHighlighter], schema, initialContent: [ { diff --git a/packages/core/package.json b/packages/core/package.json index 72b58d02c3..c22d8c774b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -105,7 +105,7 @@ "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", "lib0": "^0.2.99", - "prosemirror-highlight": "^0.15.1", + "prosemirror-highlight": "^0.15.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.3", diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index f0c4100fe4..7f580c8d7b 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -23,6 +23,8 @@ import { PreviousBlockTypeExtension, ShowSelectionExtension, SideMenuExtension, + SourceBlockWithPreviewExtension, + SourceInlineContentWithPreviewExtension, SuggestionMenu, TableHandlesExtension, TrailingNodeExtension, @@ -174,6 +176,8 @@ export function getDefaultExtensions( PlaceholderExtension(options), ShowSelectionExtension(options), SideMenuExtension(options), + SourceBlockWithPreviewExtension(), + SourceInlineContentWithPreviewExtension(), SuggestionMenu(options), HistoryExtension(), InlineContentBoundaryEditExtension(), diff --git a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts index 75f65ffcd8..3d6bf4e612 100644 --- a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts +++ b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts @@ -7,36 +7,17 @@ import { } from "../../editor/BlockNoteExtension.js"; import { Block } from "../../blocks/index.js"; +/** + * A single editor-wide extension that drives the source popup for blocks that + * render a preview. Which blocks it activates on is decided by each spec's + * `meta.hasPreview` flag, so individual blocks opt in rather than the extension + * being configured with a block type. + * + * The extension is registered once (it's a default extension) and is a no-op + * when no block declares `meta.hasPreview`. + */ export const SourceBlockWithPreviewExtension = createExtension( - ({ - editor, - options: { - key, - blockType, - hasPreview, - enterBehaviour = "close", - runsBefore = [], - }, - }: { - editor: BlockNoteEditor; - options: { - key: string; - blockType: string; - hasPreview: (block: Block) => boolean; - /** - * What pressing Enter does while the popup is open: - * - `"close"`: commits the source and closes the popup - for - * single-line sources (e.g. math). - * - `"newline"`: inserts a line break into the source - for multiline - * sources (e.g. diagrams); the popup is then closed with Escape, the - * "OK" button, or by moving the selection out. - * - * @default "close" - */ - enterBehaviour?: "close" | "newline"; - runsBefore?: readonly string[]; - }; - }) => { + ({ editor }: { editor: BlockNoteEditor }) => { const store = createStore<{ popupOpen: string | undefined; selected: string | undefined; @@ -45,8 +26,11 @@ export const SourceBlockWithPreviewExtension = createExtension( selected: undefined, }); + // A block has a preview iff its spec's implementation declares + // `meta.hasPreview` (read from the spec, like the syntax-highlighting + // extension reads `meta.highlight`). const blockHasPreview = (block: Block) => - block.type === blockType && hasPreview(block); + !!editor.schema.blockSpecs[block.type]?.implementation?.meta?.hasPreview; const handleArrow = (direction: "prev" | "next") => @@ -70,11 +54,10 @@ export const SourceBlockWithPreviewExtension = createExtension( }; return { - key, + key: "sourceBlockWithPreview", store, - runsBefore, keyboardShortcuts: { - // Toggles the popup. With the "newline" Enter behaviour, Enter + // Toggles the popup. For multi-line sources (e.g. diagrams), Enter // inserts a line break while the popup is open instead of closing it. Enter: ({ editor }) => { const { block } = editor.getTextCursorPosition(); @@ -82,9 +65,18 @@ export const SourceBlockWithPreviewExtension = createExtension( return false; } + // While the popup is open on a multi-line source, insert a line break + // rather than closing. A preview block's source is multi-line when its + // spec configures Enter to insert a hard break + // (`meta.hardBreakShortcut === "enter"`), e.g. diagrams; single-line + // sources (e.g. math) leave it unset. Handled here (rather than + // falling through to the shared hard-break handler) because that + // handler currently reads `hardBreakShortcut` from the block config, + // which doesn't carry it - see the note in `KeyboardShortcutsExtension`. if ( - enterBehaviour === "newline" && - store.state.popupOpen === block.id + store.state.popupOpen === block.id && + editor.schema.blockSpecs[block.type]?.implementation?.meta + ?.hardBreakShortcut === "enter" ) { const view = editor.prosemirrorView!; view.dispatch(view.state.tr.insertText("\n")); @@ -124,7 +116,7 @@ export const SourceBlockWithPreviewExtension = createExtension( const view = editor.prosemirrorView!; const { $from } = view.state.selection; - if ($from.parent.type.name !== blockType) { + if ($from.parent.type.name !== block.type) { return false; } diff --git a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts index 918893ac1f..5a86c8ef72 100644 --- a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts +++ b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts @@ -7,8 +7,11 @@ import { } from "../../editor/BlockNoteExtension.js"; /** - * Inline-content counterpart of {@link SourceBlockWithPreviewExtension}. Drives - * the source popup for inline content with a preview. + * Inline-content counterpart of {@link SourceBlockWithPreviewExtension}. A + * single editor-wide extension that drives the source popup for inline content + * that renders a preview. Which inline content it activates on is decided by + * each spec's `meta.hasPreview` flag, so individual inline content opts in + * rather than the extension being configured with a type. * * Unlike the block version, the popup isn't toggled with a separate state flag: * it's open exactly when the selection is inside the inline content's source. @@ -16,25 +19,24 @@ import { * the selection - moving the selection in opens its popup, moving it out closes * it. Since the source popup is always laid out (just hidden via opacity), the * cursor can navigate into and out of it with the arrow keys as usual. + * + * The extension is registered once (it's a default extension) and is a no-op + * when no inline content declares `meta.hasPreview`. */ export const SourceInlineContentWithPreviewExtension = createExtension( - ({ - editor, - options: { key, inlineContentType, runsBefore = [] }, - }: { - editor: BlockNoteEditor; - options: { - key: string; - inlineContentType: string; - runsBefore?: readonly string[]; - }; - }) => { + ({ editor }: { editor: BlockNoteEditor }) => { const store = createStore<{ selected: number | undefined; }>({ selected: undefined, }); + // Inline content has a preview iff its spec's implementation declares + // `meta.hasPreview`. + const nodeHasPreview = (nodeName: string) => + !!editor.schema.inlineContentSpecs[nodeName]?.implementation?.meta + ?.hasPreview; + // Moves the selection out of the inline content, to just `"before"` or // `"after"` it, which closes the popup via the selection-change handler // below. Lets the keyboard commit-and-exit the source the same way arrowing @@ -46,7 +48,7 @@ export const SourceInlineContentWithPreviewExtension = createExtension( ({ editor }: { editor: BlockNoteEditor }) => { const { $from } = editor.prosemirrorState.selection; const node = $from.node(); - if (node.type.name !== inlineContentType) { + if (!nodeHasPreview(node.type.name)) { return false; } @@ -63,9 +65,8 @@ export const SourceInlineContentWithPreviewExtension = createExtension( }; return { - key, + key: "sourceInlineContentWithPreview", store, - runsBefore, keyboardShortcuts: { Enter: moveSelectionOut("after"), Escape: moveSelectionOut("after"), @@ -75,7 +76,7 @@ export const SourceInlineContentWithPreviewExtension = createExtension( // whole document. "Mod-a": ({ editor }) => { const { $from } = editor.prosemirrorState.selection; - if ($from.node().type.name !== inlineContentType) { + if (!nodeHasPreview($from.node().type.name)) { return false; } @@ -97,8 +98,9 @@ export const SourceInlineContentWithPreviewExtension = createExtension( const node = $from.node(); store.setState({ - selected: - node.type.name === inlineContentType ? $from.before() : undefined, + selected: nodeHasPreview(node.type.name) + ? $from.before() + : undefined, }); }); signal.addEventListener("abort", unsubscribeSelectionChange); @@ -116,7 +118,7 @@ export const SourceInlineContentWithPreviewExtension = createExtension( // When the selection is already inside a source, leave navigation // (moving within or out of it) to the browser as usual. const { $from } = editor.prosemirrorState.selection; - if ($from.node().type.name === inlineContentType) { + if (nodeHasPreview($from.node().type.name)) { return; } diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts index 8224149108..3ad6cbef0a 100644 --- a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts +++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { SyntaxHighlightingExtension } from "./SyntaxHighlighting.js"; +import { + collectHighlightNodeTypes, + SyntaxHighlightingExtension, +} from "./SyntaxHighlighting.js"; /** * @vitest-environment jsdom @@ -37,3 +40,81 @@ describe("SyntaxHighlightingExtension", () => { expect(pluginsFor({})).toHaveLength(1); }); }); + +describe("collectHighlightNodeTypes", () => { + const highlight = () => "latex"; + + it("includes blocks with `content: inline` and a `meta.highlight`", () => { + const types = collectHighlightNodeTypes({ + blockSpecs: { + // Highlightable: inline content + a highlight callback. + math: { + config: { type: "math", content: "inline" }, + implementation: { meta: { highlight } }, + }, + // Not highlightable: no highlight callback. + paragraph: { + config: { type: "paragraph", content: "inline" }, + implementation: { meta: {} }, + }, + // Not highlightable: `content: none` holds no editable text. + image: { + config: { type: "image", content: "none" }, + implementation: { meta: { highlight } }, + }, + }, + inlineContentSpecs: {}, + }); + + expect(types).toEqual(["math"]); + }); + + it("includes inline content with `content: styled` and a `meta.highlight`", () => { + const types = collectHighlightNodeTypes({ + blockSpecs: {}, + inlineContentSpecs: { + // Highlightable: styled (editable rich text) + a highlight callback. + inlineMath: { + config: { type: "inlineMath", content: "styled" }, + implementation: { meta: { highlight } }, + }, + // Not highlightable: no highlight callback. + mention: { + config: { type: "mention", content: "styled" }, + implementation: { meta: {} }, + }, + // Not highlightable: `content: none` holds no editable text. + tag: { + config: { type: "tag", content: "none" }, + implementation: { meta: { highlight } }, + }, + // Built-in `text`/`link` specs have string configs, not objects. + text: { config: "text", implementation: undefined }, + link: { config: "link", implementation: undefined }, + }, + }); + + expect(types).toEqual(["inlineMath"]); + }); + + it("collects both block and inline-content highlight types together", () => { + const types = collectHighlightNodeTypes({ + blockSpecs: { + math: { + config: { type: "math", content: "inline" }, + implementation: { meta: { highlight } }, + }, + }, + inlineContentSpecs: { + inlineMath: { + config: { type: "inlineMath", content: "styled" }, + implementation: { meta: { highlight } }, + }, + }, + }); + + expect(types).toContain("math"); + expect(types).toContain("inlineMath"); + expect(types).toHaveLength(2); + }); +}); diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts index 7c7cff0c8d..309deac375 100644 --- a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts +++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts @@ -4,7 +4,11 @@ import { ExtensionOptions, } from "../../editor/BlockNoteExtension.js"; import { lazyShikiPlugin } from "./shiki.js"; -import { LooseBlockSpec } from "../../schema/index.js"; +import { + CustomInlineContentConfig, + InlineContentSpec, + LooseBlockSpec, +} from "../../schema/index.js"; export type SyntaxHighlightingOptions = { /** @@ -17,30 +21,64 @@ export type SyntaxHighlightingOptions = { }; /** - * A single editor-wide extension that syntax-highlights block content. Which - * blocks get highlighted (and as which language) is decided by each spec's - * `meta.highlight` callback, so individual blocks declare their own language - * rather than the extension configuring them. + * Collects the node type names that should be syntax-highlighted from a schema's + * block and inline-content specs. + * + * A spec is a candidate when it has a `meta.highlight` callback (which decides + * the language) AND the node actually holds editable text. Block and + * inline-content specs use different `content` value spaces, so "editable text" + * means `content === "inline"` for blocks and `content === "styled"` for inline + * content - hence the two are filtered separately. + * + * Inline content (e.g. inline math) is highlighted too: `prosemirror-highlight` + * collects nodes by `node.inlineContent` since v0.15.3 + * (https://github.com/ocavue/prosemirror-highlight/pull/137), so inline nodes + * holding inline content are visited alongside text blocks. + */ +export function collectHighlightNodeTypes(schema: { + blockSpecs: Record; + inlineContentSpecs: Record; +}): string[] { + const blockNodeTypes = Object.values(schema.blockSpecs) + .filter( + (blockSpec): blockSpec is LooseBlockSpec => + typeof (blockSpec as LooseBlockSpec)?.config === "object" && + (blockSpec as LooseBlockSpec).config.content === "inline" && + !!(blockSpec as LooseBlockSpec).implementation?.meta?.highlight, + ) + .map((blockSpec) => blockSpec.config.type); + + const inlineContentNodeTypes = Object.values(schema.inlineContentSpecs) + .filter( + ( + inlineContentSpec, + ): inlineContentSpec is InlineContentSpec => + typeof ( + inlineContentSpec as InlineContentSpec + )?.config === "object" && + (inlineContentSpec as InlineContentSpec) + .config.content === "styled" && + !!(inlineContentSpec as InlineContentSpec) + .implementation?.meta?.highlight, + ) + .map((inlineContentSpec) => inlineContentSpec.config.type); + + return [...blockNodeTypes, ...inlineContentNodeTypes]; +} + +/** + * A single editor-wide extension that syntax-highlights block and inline-content + * content. Which nodes get highlighted (and as which language) is decided by + * each spec's `meta.highlight` callback, so individual specs declare their own + * language rather than the extension configuring them. * * Highlighting is opt-in: the user adds this extension to the editor's * `extensions` (configured with a `createHighlighter`) to enable it. When it's - * absent, blocks render as plain text. + * absent, content renders as plain text. */ export const SyntaxHighlightingExtension = createExtension( ({ editor, options }: ExtensionOptions) => { - // Every block/inline-content spec with a `meta.highlight` callback is a - // candidate; that callback decides the language to highlight it as. - const nodeTypes = [ - ...Object.values(editor.schema.blockSpecs), - ...Object.values(editor.schema.inlineContentSpecs), - ] - .filter( - (blockSpec): blockSpec is LooseBlockSpec => - typeof blockSpec.config === "object" && - blockSpec.config.content === "inline" && - !!blockSpec.implementation?.meta?.highlight, - ) - .map((blockSpec) => blockSpec.config.type); + const nodeTypes = collectHighlightNodeTypes(editor.schema); return { key: "syntaxHighlighting", diff --git a/packages/core/src/extensions/SyntaxHighlighting/inlineHighlight.test.ts b/packages/core/src/extensions/SyntaxHighlighting/inlineHighlight.test.ts new file mode 100644 index 0000000000..96e03418e7 --- /dev/null +++ b/packages/core/src/extensions/SyntaxHighlighting/inlineHighlight.test.ts @@ -0,0 +1,135 @@ +import { Schema } from "prosemirror-model"; +import { EditorState, PluginKey } from "prosemirror-state"; +import { Decoration } from "prosemirror-view"; +import { createHighlightPlugin } from "prosemirror-highlight"; +import { describe, expect, it } from "vite-plus/test"; + +/** + * @vitest-environment jsdom + */ + +// Regression coverage for inline syntax highlighting. `prosemirror-highlight` +// used to collect only text-block nodes, so inline nodes (e.g. inline math) +// were never highlighted. Since v0.15.3 it collects nodes by +// `node.inlineContent` (https://github.com/ocavue/prosemirror-highlight/pull/137), +// so inline content with a `meta.highlight` is highlighted too. This suite +// guards that behavior. It drives the plugin directly against a hand-built +// ProseMirror doc, so it needs neither Shiki nor a browser. +describe("inline syntax highlighting", () => { + // A minimal schema with an *inline* node type that holds inline content + // (`content: "text*"`), mirroring how inline content like inline math is + // structured, plus an *atom* inline node that holds no content. + const schema = new Schema({ + nodes: { + text: { group: "inline" }, + inlineCode: { + group: "inline", + inline: true, + content: "text*", + toDOM: () => ["span", 0], + }, + // An atom inline node (no inline content) - like a mention. It should + // never be collected for highlighting even if named in `nodeTypes`, since + // it holds no editable text. This is why the library keys off + // `node.inlineContent` rather than merely `node.isInline`. + mention: { + group: "inline", + inline: true, + atom: true, + toDOM: () => ["span", "@x"], + }, + paragraph: { + group: "block", + content: "inline*", + toDOM: () => ["p", 0], + }, + doc: { content: "block+" }, + }, + }); + + const docWithInline = schema.node("doc", null, [ + schema.node("paragraph", null, [ + schema.text("before "), + schema.node("inlineCode", null, [schema.text("const x = 1")]), + schema.text(" "), + schema.node("mention"), + schema.text(" after"), + ]), + ]); + + it("calls the parser with the inline node's content and decorates it", () => { + const seen: { content: string; language?: string }[] = []; + + const plugin = createHighlightPlugin({ + // Synchronous stub parser mirroring how the real Shiki parser emits token + // decorations: `Decoration.inline` over positions *inside* the node, + // starting at `pos + 1` (the node's content starts after its opening + // boundary). Node-spanning decorations aren't valid over an inline node, + // which is fine - the token decorations are what actually color the text. + parser: ({ content, language, pos }) => { + seen.push({ content, language }); + return [ + Decoration.inline(pos + 1, pos + 1 + content.length, { + class: "hl", + }), + ]; + }, + nodeTypes: ["inlineCode"], + languageExtractor: () => "javascript", + }); + + const state = EditorState.create({ + schema, + doc: docWithInline, + plugins: [plugin], + }); + + const key = (plugin as any).spec.key as PluginKey; + const pluginState = key.getState(state); + + // The inline node's text reached the parser - proving inline nodes are + // collected (with the pre-0.15.3 library, `seen` would be empty). + expect(seen).toHaveLength(1); + expect(seen[0].content).toBe("const x = 1"); + expect(seen[0].language).toBe("javascript"); + + // And a decoration was produced for it. + expect(pluginState.decorations).toBeDefined(); + expect(pluginState.decorations.find().length).toBeGreaterThan(0); + }); + + it("still leaves non-matching inline nodes untouched", () => { + const seen: string[] = []; + const plugin = createHighlightPlugin({ + parser: ({ content }) => { + seen.push(content); + return []; + }, + nodeTypes: ["somethingElse"], + languageExtractor: () => "javascript", + }); + + EditorState.create({ schema, doc: docWithInline, plugins: [plugin] }); + + expect(seen).toHaveLength(0); + }); + + it("excludes atom inline nodes even when named in `nodeTypes`", () => { + const seen: string[] = []; + const plugin = createHighlightPlugin({ + parser: ({ content }) => { + seen.push(content); + return []; + }, + // `mention` is an atom inline node with no inline content, so it holds no + // text to highlight. The library keys off `node.inlineContent`, not + // `node.isInline`, so it's correctly skipped. + nodeTypes: ["mention"], + languageExtractor: () => "javascript", + }); + + EditorState.create({ schema, doc: docWithInline, plugins: [plugin] }); + + expect(seen).toHaveLength(0); + }); +}); diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 487b771d7e..40b9d25e23 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -804,6 +804,14 @@ export const KeyboardShortcutsExtension = Extension.create<{ commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); + // NOTE: This likely doesn't work as intended - `blockSchema[type]` + // holds the block *config* (type/propSchema/content), which carries + // no `meta`, so `meta?.hardBreakShortcut` is always `undefined` and + // this falls back to the default. It should read from the block + // spec's implementation instead (i.e. + // `editor.schema.blockSpecs[type].implementation.meta`), the way the + // syntax-highlighting extension reads `meta.highlight`. Left as-is + // for a follow-up pass. const blockHardBreakShortcut = this.options.editor.schema.blockSchema[ blockInfo.blockNoteType as keyof typeof this.options.editor.schema.blockSchema diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 0690496a05..0d06d34a04 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -70,6 +70,13 @@ export interface BlockConfigMeta< * Enables syntax highlighting of the contents of the block with the result of this callback */ highlight?(block: { type: TName; props: Props }): string | undefined; + + /** + * Marks the block as rendering a preview with an editable source popup, driven + * by the editor-wide `SourceBlockWithPreviewExtension`. When `true`, the + * block's source is hidden behind its preview and edited via the popup. + */ + hasPreview?: boolean; } /** diff --git a/packages/core/src/schema/inlineContent/createSpec.ts b/packages/core/src/schema/inlineContent/createSpec.ts index 1e49d545dd..fa560f91d7 100644 --- a/packages/core/src/schema/inlineContent/createSpec.ts +++ b/packages/core/src/schema/inlineContent/createSpec.ts @@ -32,6 +32,22 @@ export type CustomInlineContentImplementation< meta?: { draggable?: boolean; code?: boolean; + /** + * When {@link code} is `true`, this can syntax highlight the contents of the + * inline content with the result of this callback. + */ + // Method syntax (rather than an arrow-function property) so its parameter is + // checked bivariantly, keeping a specific implementation assignable to the + // generic spec record type. + highlight?( + inlineContent: Pick, "type" | "props">, + ): string | undefined; + /** + * Marks the inline content as rendering a preview with an editable source + * popup, driven by the editor-wide + * `SourceInlineContentWithPreviewExtension`. + */ + hasPreview?: boolean; }; /** diff --git a/packages/core/src/schema/inlineContent/types.ts b/packages/core/src/schema/inlineContent/types.ts index 2b4fd9acfa..4c27a1666b 100644 --- a/packages/core/src/schema/inlineContent/types.ts +++ b/packages/core/src/schema/inlineContent/types.ts @@ -43,6 +43,12 @@ export type InlineContentImplementation = * Whether the inline content is draggable */ draggable?: boolean; + /** + * Marks the inline content as rendering a preview with an editable + * source popup, driven by the editor-wide + * `SourceInlineContentWithPreviewExtension`. + */ + hasPreview?: boolean; }; node: Node; toExternalHTML?: ( diff --git a/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx b/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx index 19cd2fe8c6..b49143daac 100644 --- a/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx +++ b/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx @@ -1,7 +1,4 @@ -import { - createBlockConfig, - SourceBlockWithPreviewExtension, -} from "@blocknote/core"; +import { createBlockConfig } from "@blocknote/core"; import { createReactBlockSpec } from "@blocknote/react"; import { @@ -31,6 +28,21 @@ export const createReactDiagramBlockSpec = createReactBlockSpec( code: true, defining: true, isolating: false, + // Diagram source is Mermaid, so highlight it as such when the syntax + // highlighting extension is present. + // + // NOTE: this currently has no visible effect. Shiki's Mermaid grammar is + // a Markdown injection (`injectionSelector: "L:text.html.markdown"`) that + // only tokenizes inside a ```` ```mermaid ```` fence, so it produces no + // tokens for bare Mermaid source. See + // https://github.com/shikijs/shiki/issues/973 - once that's resolved + // upstream, highlighting should start working with no change here. + highlight: () => "mermaid", + // Diagram blocks always render a preview. Diagram sources span multiple + // lines, so Enter inserts a line break instead of closing the popup + // (`hardBreakShortcut: "enter"`). + hasPreview: true, + hardBreakShortcut: "enter", }, parse: parseDiagramCodeElement, parseContent: parseDiagramCodeContent, @@ -48,14 +60,4 @@ export const createReactDiagramBlockSpec = createReactBlockSpec( ), }, - [ - // Diagram blocks always render a preview. Diagram sources span multiple - // lines, so Enter inserts a line break instead of closing the popup. - SourceBlockWithPreviewExtension({ - key: "diagram-block-preview", - blockType: "diagram", - hasPreview: () => true, - enterBehaviour: "newline", - }), - ], ); diff --git a/packages/math-block/src/block/createReactMathBlockSpec.test.tsx b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx index 1891fa5e0c..c694e5b7c6 100644 --- a/packages/math-block/src/block/createReactMathBlockSpec.test.tsx +++ b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx @@ -129,6 +129,23 @@ describe("Math block source popup keyboard handling", () => { expect(editor.getTextCursorPosition().block.id).toBe("math"); }); + it("Enter commits without inserting a line break (single-line source)", async () => { + pressKey("Enter"); + await flush(); + expect(isPopupOpen("math")).toBe(true); + + // The math source is single-line (no `hardBreakShortcut: "enter"`), so + // unlike the diagram block, Enter closes the popup rather than extending + // the source with a newline. + pressKey("Enter"); + await flush(); + + expect(isPopupOpen("math")).toBe(false); + expect(editor.getBlock("math")!.content).toEqual([ + { type: "text", text: "a^2", styles: {} }, + ]); + }); + it("Escape closes the source popup while editing", async () => { pressKey("Enter"); await flush(); diff --git a/packages/math-block/src/block/createReactMathBlockSpec.tsx b/packages/math-block/src/block/createReactMathBlockSpec.tsx index 2c300c4068..a98b5629cf 100644 --- a/packages/math-block/src/block/createReactMathBlockSpec.tsx +++ b/packages/math-block/src/block/createReactMathBlockSpec.tsx @@ -1,7 +1,4 @@ -import { - createBlockConfig, - SourceBlockWithPreviewExtension, -} from "@blocknote/core"; +import { createBlockConfig } from "@blocknote/core"; import { createReactBlockSpec } from "@blocknote/react"; import { MathBlockInputRulesExtension } from "./helpers/extensions/MathBlockInputRulesExtension.js"; @@ -12,8 +9,6 @@ import { import { MathBlockPreviewWithPopup } from "./helpers/render/MathBlockPreviewWithPopup.js"; import { BlockMathMLElement } from "./helpers/toExternalHTML/BlockMathMLElement.js"; -const MATH_BLOCK_PREVIEW_KEY = "math-block-preview"; - export const createMathBlockConfig = createBlockConfig( () => ({ @@ -33,19 +28,14 @@ export const createReactMathBlockSpec = createReactBlockSpec( defining: true, isolating: false, highlight: () => "latex", + // Math blocks always render a preview (single-line source, so Enter + // commits/closes the popup - no `hardBreakShortcut` needed). + hasPreview: true, }, parse: parseBlockMathMLElement, parseContent: parseBlockMathMLContent, render: MathBlockPreviewWithPopup, toExternalHTML: BlockMathMLElement, }, - [ - // Math blocks always render a preview. - SourceBlockWithPreviewExtension({ - key: MATH_BLOCK_PREVIEW_KEY, - blockType: createMathBlockConfig().type, - hasPreview: () => true, - }), - MathBlockInputRulesExtension, - ], + [MathBlockInputRulesExtension], ); diff --git a/packages/math-block/src/inlineContent/createReactMathInlineContentSpec.tsx b/packages/math-block/src/inlineContent/createReactMathInlineContentSpec.tsx index e9ce66f3b5..c81a8afcbe 100644 --- a/packages/math-block/src/inlineContent/createReactMathInlineContentSpec.tsx +++ b/packages/math-block/src/inlineContent/createReactMathInlineContentSpec.tsx @@ -1,7 +1,4 @@ -import { - CustomInlineContentConfig, - SourceInlineContentWithPreviewExtension, -} from "@blocknote/core"; +import { CustomInlineContentConfig } from "@blocknote/core"; import { createReactInlineContentSpec } from "@blocknote/react"; import { MathInlineInputRulesExtension } from "./helpers/extensions/MathInlineInputRulesExtension.js"; @@ -12,8 +9,6 @@ import { import { MathInlinePreviewWithPopup } from "./helpers/render/MathInlinePreviewWithPopup.js"; import { InlineMathMLElement } from "./helpers/toExternalHTML/InlineMathMLElement.js"; -const INLINE_MATH_PREVIEW_KEY = "inline-math-preview"; - export const mathInlineContentConfig = { type: "inlineMath" as const, propSchema: {}, @@ -28,17 +23,14 @@ export const createReactInlineMathSpec = () => { meta: { code: true, + highlight: () => "latex", + // Inline math always renders a preview with an editable source popup. + hasPreview: true, }, parse: parseInlineMathMLElement, parseContent: parseInlineMathMLContent, render: MathInlinePreviewWithPopup, toExternalHTML: InlineMathMLElement, }, - [ - SourceInlineContentWithPreviewExtension({ - key: INLINE_MATH_PREVIEW_KEY, - inlineContentType: mathInlineContentConfig.type, - }), - MathInlineInputRulesExtension, - ], + [MathInlineInputRulesExtension], ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11daab0edb..9cd30aca6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4782,8 +4782,8 @@ importers: specifier: ^0.2.99 version: 0.2.117 prosemirror-highlight: - specifier: ^0.15.1 - version: 0.15.1(@shikijs/types@4.0.2)(@types/hast@3.0.4)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8) + specifier: ^0.15.3 + version: 0.15.3(@shikijs/types@4.0.2)(@types/hast@3.0.4)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8) prosemirror-model: specifier: ^1.25.4 version: 1.25.4 @@ -14176,8 +14176,8 @@ packages: prosemirror-gapcursor@1.4.1: resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} - prosemirror-highlight@0.15.1: - resolution: {integrity: sha512-KcJUGNgqLED+eK/cisNtY3M+eDNLkZyWCdyi7B3RoW3rKHnhkKawnJAcr9p1F/e3q+oDB5Y5OiIrC11bxP7tFA==} + prosemirror-highlight@0.15.3: + resolution: {integrity: sha512-WVV2st0fX1w2TkAgmTmdbj77BlWYuLfW4BGXPo8JfIWsSly5xYcfID8QJQL+GT8kisMRuu4jgT6JAqj1DOwvvg==} peerDependencies: '@lezer/common': ^1.0.0 '@lezer/highlight': ^1.0.0 @@ -25295,7 +25295,7 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-view: 1.41.8 - prosemirror-highlight@0.15.1(@shikijs/types@4.0.2)(@types/hast@3.0.4)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8): + prosemirror-highlight@0.15.3(@shikijs/types@4.0.2)(@types/hast@3.0.4)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8): optionalDependencies: '@shikijs/types': 4.0.2 '@types/hast': 3.0.4