diff --git a/docs/app/pricing/tiers.tsx b/docs/app/pricing/tiers.tsx index 2014f57b62..af1145c554 100644 --- a/docs/app/pricing/tiers.tsx +++ b/docs/app/pricing/tiers.tsx @@ -4,7 +4,7 @@ import { cn } from "@/lib/fumadocs/cn"; import * as Sentry from "@sentry/nextjs"; import { track } from "@vercel/analytics"; import { CheckIcon } from "lucide-react"; -import React from "react"; +import React, { useState } from "react"; type Frequency = "month" | "year"; @@ -37,11 +37,12 @@ function TierCTAButton({ frequency: Frequency; }) { const { data: session } = useSession(); + const [isLoading, setIsLoading] = useState(false); let text = tier.cta === "get-started" ? "Get Started" : tier.cta === "buy" - ? "Sign up" + ? "Buy now" : tier.cta === "contact" ? "Contact us" : "Sign up"; @@ -71,6 +72,7 @@ function TierCTAButton({ !isPurple && !isGreen && "bg-white border border-stone-300 text-stone-900 hover:border-purple-300 hover:text-purple-600", + isLoading && "pointer-events-none opacity-70", ); return ( @@ -80,18 +82,19 @@ function TierCTAButton({ return; } - track("Signup", { tier: tier.id }); - if (!session) { - Sentry.captureEvent({ - message: "click-pricing-signup", - level: "info", - extra: { tier: tier.id }, - }); - track("click-pricing-signup", { tier: tier.id }); + // Prevent repeat clicks from opening duplicate checkout sessions while + // the request is in flight. + if (isLoading) { + e.preventDefault(); + e.stopPropagation(); return; } - if (session.planType === "free") { + track("Signup", { tier: tier.id }); + if (!session || session.planType === "free") { + // Pay-first: logged-out buyers go straight to checkout (no sign-up + // wall). They're reconciled to an account by email in the webhook and + // emailed a sign-in link (see lib/auth.ts). Sentry.captureEvent({ message: "click-pricing-buy-now", level: "info", @@ -104,7 +107,16 @@ function TierCTAButton({ frequency === "year" && tier.id === "business" ? "business-yearly" : tier.id; - await authClient.checkout({ slug: checkoutSlug }); + setIsLoading(true); + try { + const ret = await authClient.checkout({ slug: checkoutSlug }); + if (ret?.error) { + throw new Error(JSON.stringify(ret.error)); + } + } catch (err) { + Sentry.captureException(err); + setIsLoading(false); + } } else { const isCurrentPlan = tier.id === "business" @@ -127,11 +139,18 @@ function TierCTAButton({ } e.preventDefault(); e.stopPropagation(); - await authClient.customer.portal(); + setIsLoading(true); + try { + await authClient.customer.portal(); + } catch (err) { + Sentry.captureException(err); + setIsLoading(false); + } } }} - href={tier.href ?? (session ? undefined : "/signup")} + href={tier.href ?? undefined} aria-describedby={tier.id} + aria-disabled={isLoading} className={buttonClasses} > {text} diff --git a/docs/lib/auth.ts b/docs/lib/auth.ts index 654ab8e9e9..f23ac97c28 100644 --- a/docs/lib/auth.ts +++ b/docs/lib/auth.ts @@ -217,7 +217,10 @@ export const auth = betterAuth({ }, ], successUrl: "/thanks", - authenticatedUsersOnly: true, + // Pay-first: allow logged-out checkout. The buyer is reconciled to a + // BlockNote account by email in the webhook below + // (resolveUserForCustomer), then emailed a sign-in link. + authenticatedUsersOnly: false, }), portal(), webhooks({ @@ -230,11 +233,16 @@ export const auth = betterAuth({ case "subscription.revoked": case "subscription.created": case "subscription.uncanceled": { - const authContext = await auth.$context; - const userId = payload.data.customer.externalId; + // Resolve the BlockNote account for this purchase. For pay-first + // (logged-out) checkouts the customer has no externalId, so this + // creates/links an account by email and sends a sign-in link. + const userId = await resolveUserForCustomer( + payload.data.customer, + ); if (!userId) { return; } + const authContext = await auth.$context; if (payload.data.status === "active") { const productId = payload.data.product.id; const planType = Object.values(PRODUCTS).find( @@ -296,3 +304,82 @@ export const auth = betterAuth({ }), }, }); + +// For "pay-first" checkouts the buyer may not have an account yet: the Polar +// customer has no externalId because they checked out while logged out. Resolve +// the BlockNote user for a Polar customer — creating one keyed on the checkout +// email when needed — so the purchase can be provisioned and the buyer gets +// access (via a sign-in link) to the account holding their new plan. +async function resolveUserForCustomer(customer: { + id: string; + externalId?: string | null; + email?: string | null; + name?: string | null; +}): Promise { + // Authenticated purchase: the customer is already linked to a user. + if (customer.externalId) { + return customer.externalId; + } + const email = customer.email; + if (!email) { + return null; + } + + const authContext = await auth.$context; + const existing = await authContext.internalAdapter.findUserByEmail(email); + if (existing?.user) { + // Existing account, but this logged-out purchase created an unlinked Polar + // customer — link it so the buyer's subscription is found. + await linkPolarCustomer(customer.id, existing.user.id); + return existing.user.id; + } + + try { + const created = await authContext.internalAdapter.createUser({ + email, + name: customer.name || email, + emailVerified: false, + }); + // Link the Polar customer to the new account. Subscription and + // customer-portal lookups resolve a user's customer by externalId, so + // without this the buyer couldn't access or manage the plan they bought. + await linkPolarCustomer(customer.id, created.id); + // New account → email a sign-in link so they can access the plan they + // just bought. A mail failure must not block provisioning. + try { + await auth.api.signInMagicLink({ + body: { email, callbackURL: "/pricing" }, + headers: new Headers(), + }); + } catch (err) { + Sentry.captureException(err); + } + return created.id; + } catch (err) { + // A concurrent webhook for the same purchase may have created the user + // first (email is unique). Re-fetch instead of failing provisioning. + const retry = await authContext.internalAdapter.findUserByEmail(email); + if (retry?.user) { + await linkPolarCustomer(customer.id, retry.user.id); + return retry.user.id; + } + throw err; + } +} + +// Link a Polar customer to a BlockNote user by setting the customer's +// externalId. Subscription and customer-portal lookups resolve a user's Polar +// customer by `externalId === user.id`, so a logged-out (pay-first) purchase +// must be linked here or the buyer can't access/manage their subscription. +// Best-effort: a failure is reported but must not block provisioning the plan, +// and subsequent subscription events retry the link via the same path. +async function linkPolarCustomer(customerId: string, userId: string) { + try { + await polarClient.customers.update({ + id: customerId, + customerUpdate: { externalId: userId }, + }); + } catch (err) { + Sentry.captureException(err); + } +} 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/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/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/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) { 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 }),