From 90f79f8b8b502e45ef42cac5b085f57c496c6d6f Mon Sep 17 00:00:00 2001 From: Yuriy Demidov Date: Sat, 14 Mar 2026 23:04:45 +0300 Subject: [PATCH] feat(core): allow independent registration and override of node/mark specs in extensions --- .../editor/src/core/ExtensionBuilder.test.ts | 522 ++++++++++++++++++ packages/editor/src/core/ExtensionBuilder.ts | 415 +++++++++++++- 2 files changed, 933 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/core/ExtensionBuilder.test.ts b/packages/editor/src/core/ExtensionBuilder.test.ts index 72272bcdd..765c7a660 100644 --- a/packages/editor/src/core/ExtensionBuilder.test.ts +++ b/packages/editor/src/core/ExtensionBuilder.test.ts @@ -211,4 +211,526 @@ describe('ExtensionBuilder', () => { expect(fn).toThrow(Error); }); + + describe('granular add methods', () => { + it('should add node via granular methods', () => { + const toMd = jest.fn(); + const nodes = new ExtensionBuilder(logger) + .addNodeSpec('myNode', () => ({group: 'block'})) + .addMarkdownTokenParserSpec('my_node', () => ({ + name: 'myNode', + type: 'block', + })) + .addNodeSerializerSpec('myNode', () => toMd) + .build() + .nodes(); + + expect(nodes.size).toBe(1); + const nodeSpec = nodes.get('myNode'); + expect(nodeSpec).toBeTruthy(); + expect(nodeSpec!.spec.group).toBe('block'); + expect(nodeSpec!.fromMd.tokenName).toBe('my_node'); + expect(nodeSpec!.fromMd.tokenSpec.name).toBe('myNode'); + expect(nodeSpec!.fromMd.tokenSpec.type).toBe('block'); + expect(nodeSpec!.toMd).toBe(toMd); + }); + + it('should add mark via granular methods', () => { + const marks = new ExtensionBuilder(logger) + .addMarkSpec('myMark', () => ({ + parseDOM: [{tag: 'em'}], + toDOM() { + return ['em']; + }, + })) + .addMarkdownTokenParserSpec('em', () => ({ + name: 'myMark', + type: 'mark', + })) + .addMarkSerializerSpec('myMark', () => ({open: '*', close: '*'})) + .build() + .marks(); + + expect(marks.size).toBe(1); + const markSpec = marks.get('myMark'); + expect(markSpec).toBeTruthy(); + expect(markSpec!.fromMd.tokenName).toBe('em'); + expect(markSpec!.fromMd.tokenSpec.name).toBe('myMark'); + expect(markSpec!.toMd).toEqual({open: '*', close: '*'}); + }); + + it('should mix addNode and granular nodes', () => { + const nodes = new ExtensionBuilder(logger) + .addNode('node1', () => ({ + spec: {}, + fromMd: {tokenSpec: {type: 'block', name: 'node1'}}, + toMd: () => {}, + })) + .addNodeSpec('node2', () => ({group: 'block'})) + .addMarkdownTokenParserSpec('node2_token', () => ({ + name: 'node2', + type: 'block', + })) + .addNodeSerializerSpec('node2', () => () => {}) + .build() + .nodes(); + + expect(nodes.size).toBe(2); + expect(nodes.get('node1')).toBeTruthy(); + expect(nodes.get('node2')).toBeTruthy(); + }); + + it('should throw when addNodeSpec name conflicts with addNode', () => { + const builder = new ExtensionBuilder(logger).addNode('node', () => ({ + spec: {}, + fromMd: {tokenSpec: {type: 'block', name: 'node'}}, + toMd: () => {}, + })); + + expect(() => builder.addNodeSpec('node', () => ({}))).toThrow( + /already registered via addNode/, + ); + }); + + it('should throw when addMarkSpec name conflicts with addMark', () => { + const builder = new ExtensionBuilder(logger).addMark('mark', () => ({ + spec: {}, + fromMd: {tokenSpec: {type: 'mark', name: 'mark'}}, + toMd: {open: '', close: ''}, + })); + + expect(() => builder.addMarkSpec('mark', () => ({}))).toThrow( + /already registered via addMark/, + ); + }); + + it('should throw when addNodeSpec called twice with same name', () => { + const builder = new ExtensionBuilder(logger).addNodeSpec('node', () => ({})); + + expect(() => builder.addNodeSpec('node', () => ({}))).toThrow( + /already registered via addNodeSpec/, + ); + }); + + it('should throw when addMarkdownTokenParserSpec called twice with same tokenName', () => { + const builder = new ExtensionBuilder(logger).addMarkdownTokenParserSpec('tok', () => ({ + name: 'a', + type: 'block', + })); + + expect(() => + builder.addMarkdownTokenParserSpec('tok', () => ({name: 'b', type: 'block'})), + ).toThrow(/already registered via addMarkdownTokenParserSpec/); + }); + + it('should throw on build when granular node is missing parser spec', () => { + const builder = new ExtensionBuilder(logger) + .addNodeSpec('myNode', () => ({})) + .addNodeSerializerSpec('myNode', () => () => {}); + + expect(() => builder.build().nodes()).toThrow(/missing parser spec/); + }); + + it('should throw on build when granular node is missing serializer', () => { + const builder = new ExtensionBuilder(logger) + .addNodeSpec('myNode', () => ({})) + .addMarkdownTokenParserSpec('tok', () => ({name: 'myNode', type: 'block'})); + + expect(() => builder.build().nodes()).toThrow(/missing serializer/); + }); + + it('should throw on build when granular mark is missing parser spec', () => { + const builder = new ExtensionBuilder(logger) + .addMarkSpec('myMark', () => ({})) + .addMarkSerializerSpec('myMark', () => ({open: '', close: ''})); + + expect(() => builder.build().marks()).toThrow(/missing parser spec/); + }); + + it('should throw on build when granular mark is missing serializer', () => { + const builder = new ExtensionBuilder(logger) + .addMarkSpec('myMark', () => ({})) + .addMarkdownTokenParserSpec('tok', () => ({name: 'myMark', type: 'mark'})); + + expect(() => builder.build().marks()).toThrow(/missing serializer/); + }); + + it('should handle multiple parser tokens mapping to the same node', () => { + const toMd = jest.fn(); + const nodes = new ExtensionBuilder(logger) + .addNodeSpec('code_block', () => ({group: 'block', code: true})) + .addMarkdownTokenParserSpec('code_block', () => ({ + name: 'code_block', + type: 'block', + noCloseToken: true, + })) + .addMarkdownTokenParserSpec('fence', () => ({ + name: 'code_block', + type: 'block', + noCloseToken: true, + })) + .addNodeSerializerSpec('code_block', () => toMd) + .build() + .nodes(); + + // Main node entry + parser-only entry for the extra token + expect(nodes.size).toBe(2); + + const codeBlock = nodes.get('code_block'); + expect(codeBlock).toBeTruthy(); + expect(codeBlock!.spec.group).toBe('block'); + expect(codeBlock!.toMd).toBe(toMd); + + const fence = nodes.get('fence'); + expect(fence).toBeTruthy(); + expect(fence!.fromMd.tokenName).toBe('fence'); + expect(fence!.fromMd.tokenSpec.name).toBe('code_block'); + // Parser-only entry has empty spec and throwing serializer + expect(fence!.spec).toEqual({}); + expect(() => (fence!.toMd as Function)()).toThrow(); + }); + + it('should handle multiple parser tokens mapping to the same mark', () => { + const marksList: {name: string; spec: ExtensionMarkSpec}[] = []; + + new ExtensionBuilder(logger) + .addMarkSpec('myMark', () => ({})) + .addMarkdownTokenParserSpec('em', () => ({ + name: 'myMark', + type: 'mark', + })) + .addMarkdownTokenParserSpec('emphasis', () => ({ + name: 'myMark', + type: 'mark', + })) + .addMarkSerializerSpec('myMark', () => ({open: '*', close: '*'})) + .build() + .marks() + .forEach((name, spec) => marksList.push({name, spec})); + + // Main mark entry + parser-only entry for the extra token + expect(marksList).toHaveLength(2); + expect(marksList[0].name).toBe('myMark'); + expect(marksList[0].spec.toMd).toEqual({open: '*', close: '*'}); + expect(marksList[1].name).toBe('emphasis'); + expect(marksList[1].spec.fromMd.tokenName).toBe('emphasis'); + expect(marksList[1].spec.fromMd.tokenSpec.name).toBe('myMark'); + }); + + it('should handle addMarkdownTokenParserSpec token targeting addNode entity', () => { + const nodes = new ExtensionBuilder(logger) + .addNode('code_block', () => ({ + spec: {group: 'block', code: true}, + fromMd: { + tokenSpec: {name: 'code_block', type: 'block', noCloseToken: true}, + }, + toMd: () => {}, + })) + .addMarkdownTokenParserSpec('fence', () => ({ + name: 'code_block', + type: 'block', + noCloseToken: true, + })) + .build() + .nodes(); + + // addNode entry + parser-only entry for 'fence' + expect(nodes.size).toBe(2); + + const codeBlock = nodes.get('code_block'); + expect(codeBlock).toBeTruthy(); + expect(codeBlock!.spec.group).toBe('block'); + + const fence = nodes.get('fence'); + expect(fence).toBeTruthy(); + expect(fence!.fromMd.tokenName).toBe('fence'); + expect(fence!.fromMd.tokenSpec.name).toBe('code_block'); + expect(fence!.spec).toEqual({}); + expect(() => (fence!.toMd as Function)()).toThrow(); + }); + + it('should apply overrides to extra parser tokens', () => { + const nodes = new ExtensionBuilder(logger) + .addNodeSpec('code_block', () => ({group: 'block'})) + .addMarkdownTokenParserSpec('code_block', () => ({ + name: 'code_block', + type: 'block', + })) + .addMarkdownTokenParserSpec('fence', () => ({ + name: 'code_block', + type: 'block', + })) + .addNodeSerializerSpec('code_block', () => () => {}) + .overrideMarkdownTokenParserSpec('fence', (prev) => ({ + ...prev, + noCloseToken: true, + })) + .build() + .nodes(); + + const fence = nodes.get('fence'); + expect(fence!.fromMd.tokenSpec.noCloseToken).toBe(true); + }); + + it('should allow overrideMarkdownTokenParserSpec on addNode token', () => { + const nodes = new ExtensionBuilder(logger) + .addNode('image', () => ({ + spec: {inline: true, group: 'inline'}, + fromMd: { + tokenSpec: { + name: 'image', + type: 'node', + getAttrs: () => ({src: ''}), + }, + }, + toMd: () => {}, + })) + .overrideMarkdownTokenParserSpec('image', (prev) => ({ + ...prev, + getAttrs: () => ({src: '', width: null}), + })) + .build() + .nodes(); + + const image = nodes.get('image'); + expect(image!.fromMd.tokenSpec.getAttrs!({} as any, [] as any, 0)).toEqual({ + src: '', + width: null, + }); + }); + + it('should allow overrideMarkdownTokenParserSpec on addMark token', () => { + const marks = new ExtensionBuilder(logger) + .addMark('bold', () => ({ + spec: {}, + fromMd: { + tokenSpec: { + name: 'bold', + type: 'mark', + getAttrs: () => ({weight: 'bold'}), + }, + }, + toMd: {open: '**', close: '**'}, + })) + .overrideMarkdownTokenParserSpec('bold', (prev) => ({ + ...prev, + getAttrs: () => ({weight: 'bold', custom: true}), + })) + .build() + .marks(); + + const bold = marks.get('bold'); + expect(bold!.fromMd.tokenSpec.getAttrs!({} as any, [] as any, 0)).toEqual({ + weight: 'bold', + custom: true, + }); + }); + + it('should sort granular marks by priority together with addMark marks', () => { + const marksList: {name: string; spec: ExtensionMarkSpec}[] = []; + + new ExtensionBuilder(logger) + .addMark( + 'addMarkLow', + () => ({ + spec: {}, + fromMd: {tokenSpec: {type: 'mark', name: 'addMarkLow'}}, + toMd: {open: '', close: ''}, + }), + ExtensionBuilder.Priority.Low, + ) + .addMarkSpec('granularHigh', () => ({}), ExtensionBuilder.Priority.High) + .addMarkdownTokenParserSpec('granular_high', () => ({ + name: 'granularHigh', + type: 'mark', + })) + .addMarkSerializerSpec('granularHigh', () => ({open: '', close: ''})) + .build() + .marks() + .forEach((name, spec) => marksList.push({name, spec})); + + expect(marksList[0].name).toBe('granularHigh'); + expect(marksList[1].name).toBe('addMarkLow'); + }); + }); + + describe('override methods', () => { + it('should override node spec from addNode', () => { + const nodes = new ExtensionBuilder(logger) + .addNode('heading', () => ({ + spec: {group: 'block', content: 'inline*'}, + fromMd: {tokenSpec: {type: 'block', name: 'heading'}}, + toMd: () => {}, + })) + .overrideNodeSpec('heading', (prev) => ({...prev, group: 'block heading'})) + .build() + .nodes(); + + expect(nodes.get('heading')!.spec.group).toBe('block heading'); + expect(nodes.get('heading')!.spec.content).toBe('inline*'); + }); + + it('should override node spec from addNodeSpec', () => { + const nodes = new ExtensionBuilder(logger) + .addNodeSpec('myNode', () => ({group: 'block', content: 'inline*'})) + .addMarkdownTokenParserSpec('my_node', () => ({name: 'myNode', type: 'block'})) + .addNodeSerializerSpec('myNode', () => () => {}) + .overrideNodeSpec('myNode', (prev) => ({...prev, group: 'block custom'})) + .build() + .nodes(); + + expect(nodes.get('myNode')!.spec.group).toBe('block custom'); + expect(nodes.get('myNode')!.spec.content).toBe('inline*'); + }); + + it('should chain multiple overrides', () => { + const nodes = new ExtensionBuilder(logger) + .addNode('node', () => ({ + spec: {group: 'block', content: 'inline*', marks: ''}, + fromMd: {tokenSpec: {type: 'block', name: 'node'}}, + toMd: () => {}, + })) + .overrideNodeSpec('node', (prev) => ({...prev, group: 'block custom'})) + .overrideNodeSpec('node', (prev) => ({...prev, content: 'block+'})) + .build() + .nodes(); + + expect(nodes.get('node')!.spec.group).toBe('block custom'); + expect(nodes.get('node')!.spec.content).toBe('block+'); + expect(nodes.get('node')!.spec.marks).toBe(''); + }); + + it('should override mark spec from addMark', () => { + const marks = new ExtensionBuilder(logger) + .addMark('bold', () => ({ + spec: {parseDOM: [{tag: 'strong'}]}, + fromMd: {tokenSpec: {type: 'mark', name: 'bold'}}, + toMd: {open: '**', close: '**'}, + })) + .overrideMarkSpec('bold', (prev) => ({ + ...prev, + parseDOM: [{tag: 'strong'}, {tag: 'b'}], + })) + .build() + .marks(); + + expect(marks.get('bold')!.spec.parseDOM).toHaveLength(2); + }); + + it('should override parser spec on granular node', () => { + const nodes = new ExtensionBuilder(logger) + .addNodeSpec('myNode', () => ({})) + .addMarkdownTokenParserSpec('my_tok', () => ({ + name: 'myNode', + type: 'block', + })) + .addNodeSerializerSpec('myNode', () => () => {}) + .overrideMarkdownTokenParserSpec('my_tok', (prev) => ({ + ...prev, + noCloseToken: true, + })) + .build() + .nodes(); + + expect(nodes.get('myNode')!.fromMd.tokenSpec.noCloseToken).toBe(true); + expect(nodes.get('myNode')!.fromMd.tokenSpec.type).toBe('block'); + }); + + it('should override node serializer on addNode entry', () => { + const originalToMd = jest.fn(); + const newToMd = jest.fn(); + + const nodes = new ExtensionBuilder(logger) + .addNode('node', () => ({ + spec: {}, + fromMd: {tokenSpec: {type: 'block', name: 'node'}}, + toMd: originalToMd, + })) + .overrideNodeSerializerSpec('node', () => newToMd) + .build() + .nodes(); + + expect(nodes.get('node')!.toMd).toBe(newToMd); + }); + + it('should override mark serializer on addMark entry', () => { + const marks = new ExtensionBuilder(logger) + .addMark('em', () => ({ + spec: {}, + fromMd: {tokenSpec: {type: 'mark', name: 'em'}}, + toMd: {open: '_', close: '_'}, + })) + .overrideMarkSerializerSpec('em', () => ({open: '*', close: '*'})) + .build() + .marks(); + + expect(marks.get('em')!.toMd).toEqual({open: '*', close: '*'}); + }); + + it('should preserve view when overriding addNode entry', () => { + const viewFactory = () => (() => {}) as any; + + const nodes = new ExtensionBuilder(logger) + .addNode('node', () => ({ + spec: {group: 'block'}, + fromMd: {tokenSpec: {type: 'block', name: 'node'}}, + toMd: () => {}, + view: viewFactory, + })) + .overrideNodeSpec('node', (prev) => ({...prev, group: 'block custom'})) + .build() + .nodes(); + + expect(nodes.get('node')!.view).toBe(viewFactory); + expect(nodes.get('node')!.spec.group).toBe('block custom'); + }); + + it('should work with addNode entries when no overrides applied', () => { + const toMd = jest.fn(); + const nodes = new ExtensionBuilder(logger) + .addNode('node', () => ({ + spec: {group: 'block'}, + fromMd: {tokenSpec: {type: 'block', name: 'node'}}, + toMd, + })) + .build() + .nodes(); + + expect(nodes.get('node')!.spec.group).toBe('block'); + expect(nodes.get('node')!.toMd).toBe(toMd); + }); + + it('should throw when overriding unregistered node spec', () => { + expect(() => + new ExtensionBuilder(logger).overrideNodeSpec('unknown', (prev) => prev), + ).toThrow(/not registered/); + }); + + it('should throw when overriding unregistered mark spec', () => { + expect(() => + new ExtensionBuilder(logger).overrideMarkSpec('unknown', (prev) => prev), + ).toThrow(/not registered/); + }); + + it('should throw when overriding unregistered parser token', () => { + expect(() => + new ExtensionBuilder(logger).overrideMarkdownTokenParserSpec( + 'unknown_tok', + (prev) => prev, + ), + ).toThrow(/not registered/); + }); + + it('should throw when overriding unregistered node serializer', () => { + expect(() => + new ExtensionBuilder(logger).overrideNodeSerializerSpec('unknown', (prev) => prev), + ).toThrow(/not registered/); + }); + + it('should throw when overriding unregistered mark serializer', () => { + expect(() => + new ExtensionBuilder(logger).overrideMarkSerializerSpec('unknown', (prev) => prev), + ).toThrow(/not registered/); + }); + }); }); diff --git a/packages/editor/src/core/ExtensionBuilder.ts b/packages/editor/src/core/ExtensionBuilder.ts index 97c0cfad2..0fca8166a 100644 --- a/packages/editor/src/core/ExtensionBuilder.ts +++ b/packages/editor/src/core/ExtensionBuilder.ts @@ -2,6 +2,7 @@ import type MarkdownIt from 'markdown-it'; import OrderedMap from 'orderedmap'; import {inputRules} from 'prosemirror-inputrules'; import {keymap} from 'prosemirror-keymap'; +import type {MarkSpec, NodeSpec} from 'prosemirror-model'; import type {Plugin} from 'prosemirror-state'; import type {Logger2} from '../logger'; @@ -16,6 +17,8 @@ import type { ExtensionWithOptions, } from './types/extension'; import type {Keymap} from './types/keymap'; +import type {ParserToken} from './types/parser'; +import type {SerializerMarkToken, SerializerNodeToken} from './types/serializer'; type InputRulesConfig = Parameters[0]; type ExtensionWithParams = (builder: ExtensionBuilder, ...params: any[]) => void; @@ -64,6 +67,62 @@ declare global { } } +function applyOverrides(initial: T, overrides?: Array<(prev: T) => T>): T { + return overrides ? overrides.reduce((acc, fn) => fn(acc), initial) : initial; +} + +type ResolvedParserEntry = {tokenName: string; tokenSpec: ParserToken}; +type ParserOverridesMap = Record ParserToken>>; + +function resolveParserEntry( + entry: {tokenName: string; tokenSpec: ParserToken}, + overrides: ParserOverridesMap, +): ResolvedParserEntry { + return { + tokenName: entry.tokenName, + tokenSpec: applyOverrides(entry.tokenSpec, overrides[entry.tokenName]), + }; +} + +function resolveGranularParserEntries( + entityName: string, + entityType: 'node' | 'mark', + parserSpecsByEntity: Record, + overrides: ParserOverridesMap, +): {primary: ResolvedParserEntry; extra: ResolvedParserEntry[]} { + const entries = parserSpecsByEntity[entityName]; + if (!entries || entries.length === 0) { + throw new Error( + `Incomplete ${entityType} spec for "${entityName}": missing parser spec. ` + + `Use addMarkdownTokenParserSpec() to register a parser for this ${entityType}.`, + ); + } + + const [primaryRaw, ...extraRaw] = entries; + return { + primary: resolveParserEntry(primaryRaw, overrides), + extra: extraRaw.map((e) => resolveParserEntry(e, overrides)), + }; +} + +function buildParserOnlyNodeEntry(resolved: ResolvedParserEntry): ExtensionNodeSpec { + return { + spec: {}, + fromMd: {tokenName: resolved.tokenName, tokenSpec: resolved.tokenSpec}, + toMd: () => { + throw new Error(`Unexpected toMd() call on parser-only node "${resolved.tokenName}"`); + }, + }; +} + +function buildParserOnlyMarkEntry(resolved: ResolvedParserEntry): ExtensionMarkSpec { + return { + spec: {}, + fromMd: {tokenName: resolved.tokenName, tokenSpec: resolved.tokenSpec}, + toMd: {open: '', close: ''}, + }; +} + export class ExtensionBuilder { static createContext(): BuilderContext { return new Map(); @@ -81,6 +140,26 @@ export class ExtensionBuilder { #plugins: {cb: AddPmPluginCallback; priority: number}[] = []; #actions: [string, AddActionCallback][] = []; + // Granular add storage + #rawNodeSpecs: Record NodeSpec> = {}; + #rawMarkSpecs: Record MarkSpec; priority: number}> = {}; + #rawParserSpecs: Record ParserToken}> = {}; + #rawNodeSerializers: Record SerializerNodeToken> = {}; + #rawMarkSerializers: Record SerializerMarkToken> = {}; + + // Override chains + #nodeSpecOverrides: Record NodeSpec>> = {}; + #markSpecOverrides: Record MarkSpec>> = {}; + #parserSpecOverrides: Record ParserToken>> = {}; + #nodeSerializerOverrides: Record< + string, + Array<(prev: SerializerNodeToken) => SerializerNodeToken> + > = {}; + #markSerializerOverrides: Record< + string, + Array<(prev: SerializerMarkToken) => SerializerMarkToken> + > = {}; + readonly context: BuilderContext; constructor(logger: Logger2.ILogger, context?: BuilderContext) { @@ -110,6 +189,10 @@ export class ExtensionBuilder { return this; } + /** + * @deprecated Will be removed in the next major version. + * Use addNodeSpec() + addMarkdownTokenParserSpec() + addNodeSerializerSpec() instead. + */ addNode(name: string, cb: AddPmNodeCallback): this { if (this.#nodeSpecs[name]) { throw new Error(`ProseMirror node with this name "${name}" already exist`); @@ -118,6 +201,10 @@ export class ExtensionBuilder { return this; } + /** + * @deprecated Will be removed in the next major version. + * Use addMarkSpec() + addMarkdownTokenParserSpec() + addMarkSerializerSpec() instead. + */ addMark(name: string, cb: AddPmMarkCallback, priority = DEFAULT_PRIORITY): this { if (this.#markSpecs[name]) { throw new Error(`ProseMirror mark with this name "${name}" already exist`); @@ -151,6 +238,126 @@ export class ExtensionBuilder { return this; } + addNodeSpec(name: string, cb: () => NodeSpec): this { + if (this.#rawNodeSpecs[name]) { + throw new Error(`Node spec with name "${name}" already registered via addNodeSpec`); + } + if (this.#nodeSpecs[name]) { + throw new Error( + `Node with name "${name}" already registered via addNode. Use overrideNodeSpec to modify it.`, + ); + } + this.#rawNodeSpecs[name] = cb; + return this; + } + + addMarkSpec(name: string, cb: () => MarkSpec, priority = DEFAULT_PRIORITY): this { + if (this.#rawMarkSpecs[name]) { + throw new Error(`Mark spec with name "${name}" already registered via addMarkSpec`); + } + if (this.#markSpecs[name]) { + throw new Error( + `Mark with name "${name}" already registered via addMark. Use overrideMarkSpec to modify it.`, + ); + } + this.#rawMarkSpecs[name] = {cb, priority}; + return this; + } + + addMarkdownTokenParserSpec(tokenName: string, cb: () => ParserToken): this { + if (this.#rawParserSpecs[tokenName]) { + throw new Error( + `Parser spec for token "${tokenName}" already registered via addMarkdownTokenParserSpec`, + ); + } + this.#rawParserSpecs[tokenName] = {tokenName, cb}; + return this; + } + + addNodeSerializerSpec(name: string, cb: () => SerializerNodeToken): this { + if (this.#rawNodeSerializers[name]) { + throw new Error( + `Node serializer for "${name}" already registered via addNodeSerializerSpec`, + ); + } + this.#rawNodeSerializers[name] = cb; + return this; + } + + addMarkSerializerSpec(name: string, cb: () => SerializerMarkToken): this { + if (this.#rawMarkSerializers[name]) { + throw new Error( + `Mark serializer for "${name}" already registered via addMarkSerializerSpec`, + ); + } + this.#rawMarkSerializers[name] = cb; + return this; + } + + overrideNodeSpec(name: string, cb: (prev: NodeSpec) => NodeSpec): this { + if (!this.#nodeSpecs[name] && !this.#rawNodeSpecs[name]) { + throw new Error( + `Cannot override node spec "${name}": not registered. Use addNode() or addNodeSpec() first.`, + ); + } + (this.#nodeSpecOverrides[name] ??= []).push(cb); + return this; + } + + overrideMarkSpec(name: string, cb: (prev: MarkSpec) => MarkSpec): this { + if (!this.#markSpecs[name] && !this.#rawMarkSpecs[name]) { + throw new Error( + `Cannot override mark spec "${name}": not registered. Use addMark() or addMarkSpec() first.`, + ); + } + (this.#markSpecOverrides[name] ??= []).push(cb); + return this; + } + + overrideMarkdownTokenParserSpec( + tokenName: string, + cb: (prev: ParserToken) => ParserToken, + ): this { + if ( + !this.#rawParserSpecs[tokenName] && + !this.#nodeSpecs[tokenName] && + !this.#markSpecs[tokenName] + ) { + throw new Error( + `Cannot override parser spec for token "${tokenName}": not registered. ` + + `Use addMarkdownTokenParserSpec(), addNode(), or addMark() first.`, + ); + } + (this.#parserSpecOverrides[tokenName] ??= []).push(cb); + return this; + } + + overrideNodeSerializerSpec( + name: string, + cb: (prev: SerializerNodeToken) => SerializerNodeToken, + ): this { + if (!this.#nodeSpecs[name] && !this.#rawNodeSerializers[name]) { + throw new Error( + `Cannot override node serializer "${name}": not registered. Use addNode() or addNodeSerializerSpec() first.`, + ); + } + (this.#nodeSerializerOverrides[name] ??= []).push(cb); + return this; + } + + overrideMarkSerializerSpec( + name: string, + cb: (prev: SerializerMarkToken) => SerializerMarkToken, + ): this { + if (!this.#markSpecs[name] && !this.#rawMarkSerializers[name]) { + throw new Error( + `Cannot override mark serializer "${name}": not registered. Use addMark() or addMarkSerializerSpec() first.`, + ); + } + (this.#markSerializerOverrides[name] ??= []).push(cb); + return this; + } + build(): ExtensionSpec { const confMd = this.#confMdCbs.slice(); const nodes = {...this.#nodeSpecs}; @@ -158,6 +365,30 @@ export class ExtensionBuilder { const plugins = this.#plugins.slice(); const actions = this.#actions.slice(); + const rawNodeSpecs = {...this.#rawNodeSpecs}; + const rawMarkSpecs = {...this.#rawMarkSpecs}; + const rawParserSpecs = {...this.#rawParserSpecs}; + const rawNodeSerializers = {...this.#rawNodeSerializers}; + const rawMarkSerializers = {...this.#rawMarkSerializers}; + + const nodeSpecOverrides = {...this.#nodeSpecOverrides}; + const markSpecOverrides = {...this.#markSpecOverrides}; + const parserSpecOverrides = {...this.#parserSpecOverrides}; + const nodeSerializerOverrides = {...this.#nodeSerializerOverrides}; + const markSerializerOverrides = {...this.#markSerializerOverrides}; + + // Pre-build entity name → parser specs lookup for O(1) access + // Multiple markdown-it tokens can map to the same ProseMirror entity + // (e.g. both 'fence' and 'code_block' tokens → 'code_block' node) + const parserSpecsByEntity: Record< + string, + Array<{tokenName: string; tokenSpec: ParserToken}> + > = {}; + for (const {tokenName, cb} of Object.values(rawParserSpecs)) { + const tokenSpec = cb(); + (parserSpecsByEntity[tokenSpec.name] ??= []).push({tokenName, tokenSpec}); + } + return { configureMd: (md, parserType) => confMd.reduce((pMd, {cb, params}) => { @@ -171,18 +402,194 @@ export class ExtensionBuilder { }, md), nodes: () => { let map = OrderedMap.from({}); + + // 1. Process addNode entries with overrides for (const {name, cb} of Object.values(nodes)) { - map = map.addToEnd(name, cb()); + const base = cb(); + const tokenName = base.fromMd.tokenName ?? name; + const hasOverrides = + nodeSpecOverrides[name] || + parserSpecOverrides[tokenName] || + nodeSerializerOverrides[name]; + + if (hasOverrides) { + map = map.addToEnd(name, { + spec: applyOverrides(base.spec, nodeSpecOverrides[name]), + fromMd: { + tokenName: base.fromMd.tokenName, + tokenSpec: applyOverrides( + base.fromMd.tokenSpec, + parserSpecOverrides[tokenName], + ), + }, + toMd: applyOverrides(base.toMd, nodeSerializerOverrides[name]), + view: base.view, + }); + } else { + map = map.addToEnd(name, base); + } + } + + // 1b. Add parser-only entries for rawParserSpecs tokens targeting addNode entities + for (const {name} of Object.values(nodes)) { + const entries = parserSpecsByEntity[name]; + if (entries) { + for (const entry of entries) { + map = map.addToEnd( + entry.tokenName, + buildParserOnlyNodeEntry( + resolveParserEntry(entry, parserSpecOverrides), + ), + ); + } + } } + + // 2. Process granular-only nodes + for (const name of Object.keys(rawNodeSpecs)) { + const spec = applyOverrides(rawNodeSpecs[name](), nodeSpecOverrides[name]); + + const {primary, extra} = resolveGranularParserEntries( + name, + 'node', + parserSpecsByEntity, + parserSpecOverrides, + ); + + if (!rawNodeSerializers[name]) { + throw new Error( + `Incomplete node spec for "${name}": missing serializer. ` + + `Use addNodeSerializerSpec() to register a serializer for this node.`, + ); + } + const toMd = applyOverrides( + rawNodeSerializers[name](), + nodeSerializerOverrides[name], + ); + + map = map.addToEnd(name, { + spec, + fromMd: {tokenName: primary.tokenName, tokenSpec: primary.tokenSpec}, + toMd, + }); + + for (const entry of extra) { + map = map.addToEnd(entry.tokenName, buildParserOnlyNodeEntry(entry)); + } + } + return map; }, marks: () => { // The order of marks in schema is important when serializing pm-document to DOM or markup // https://discuss.prosemirror.net/t/marks-priority/4463 - const sortedMarks = Object.values(marks).sort((a, b) => b.priority - a.priority); + + // 1. Process addMark entries with overrides + const allMarks: { + name: string; + priority: number; + buildSpec: () => ExtensionMarkSpec; + }[] = []; + + for (const {name, cb, priority} of Object.values(marks)) { + allMarks.push({ + name, + priority, + buildSpec: () => { + const base = cb(); + const tokenName = base.fromMd.tokenName ?? name; + const hasOverrides = + markSpecOverrides[name] || + parserSpecOverrides[tokenName] || + markSerializerOverrides[name]; + + if (hasOverrides) { + return { + spec: applyOverrides(base.spec, markSpecOverrides[name]), + fromMd: { + tokenName: base.fromMd.tokenName, + tokenSpec: applyOverrides( + base.fromMd.tokenSpec, + parserSpecOverrides[tokenName], + ), + }, + toMd: applyOverrides(base.toMd, markSerializerOverrides[name]), + view: base.view, + }; + } + return base; + }, + }); + } + + // 1b. Add parser-only entries for rawParserSpecs tokens targeting addMark entities + for (const {name, priority} of Object.values(marks)) { + const entries = parserSpecsByEntity[name]; + if (entries) { + for (const entry of entries) { + allMarks.push({ + name: entry.tokenName, + priority, + buildSpec: () => + buildParserOnlyMarkEntry( + resolveParserEntry(entry, parserSpecOverrides), + ), + }); + } + } + } + + // 2. Process granular-only marks + for (const name of Object.keys(rawMarkSpecs)) { + const {cb: specCb, priority} = rawMarkSpecs[name]; + + const {primary, extra} = resolveGranularParserEntries( + name, + 'mark', + parserSpecsByEntity, + parserSpecOverrides, + ); + + if (!rawMarkSerializers[name]) { + throw new Error( + `Incomplete mark spec for "${name}": missing serializer. ` + + `Use addMarkSerializerSpec() to register a serializer for this mark.`, + ); + } + + allMarks.push({ + name, + priority, + buildSpec: () => { + const spec = applyOverrides(specCb(), markSpecOverrides[name]); + const toMd = applyOverrides( + rawMarkSerializers[name](), + markSerializerOverrides[name], + ); + return { + spec, + fromMd: { + tokenName: primary.tokenName, + tokenSpec: primary.tokenSpec, + }, + toMd, + }; + }, + }); + + for (const entry of extra) { + allMarks.push({ + name: entry.tokenName, + priority, + buildSpec: () => buildParserOnlyMarkEntry(entry), + }); + } + } + + allMarks.sort((a, b) => b.priority - a.priority); let map = OrderedMap.from({}); - for (const {name, cb} of sortedMarks) { - map = map.addToEnd(name, cb()); + for (const {name, buildSpec} of allMarks) { + map = map.addToEnd(name, buildSpec()); } return map; },