diff --git a/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts b/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts index c5cc5d72a9f8..6cd60b53b25c 100644 --- a/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts +++ b/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts @@ -77,6 +77,7 @@ import { GridBlock, ImageNode, LoaderNode, + UnsupportedBlockMark, UnsupportedBlockNode, VideoNode } from '../../nodes'; @@ -87,6 +88,7 @@ import { removeInvalidNodes, restoreUnknownBlockNodes, RestoreDefaultDOMAttrs, + preserveUnknownBlockMarks, preserveUnknownBlockNodes, SetDocAttrStep } from '../../shared'; @@ -115,6 +117,7 @@ export class DotBlockEditorComponent implements OnInit, OnChanges, OnDestroy, Co readonly #injector = inject(Injector); /** Schema node names captured as soon as the TipTap editor instance exists. */ readonly #knownEditorNodeNames = new Set(); + readonly #knownEditorMarkNames = new Set(); /** Buffers incoming form content until the editor create lifecycle can safely consume it. */ #pendingValue: Content | null = null; /** Field-level allowed blocks, with paragraph forced in as the legacy default. */ @@ -269,6 +272,10 @@ export class DotBlockEditorComponent implements OnInit, OnChanges, OnDestroy, Co Object.keys(this.editor.schema.nodes).forEach((nodeName) => this.#knownEditorNodeNames.add(nodeName) ); + this.#knownEditorMarkNames.clear(); + Object.keys(this.editor.schema.marks).forEach((markName) => + this.#knownEditorMarkNames.add(markName) + ); this.dotMarketingConfigService.setProperty( EDITOR_MARKETING_KEYS.SHOW_VIDEO_THUMBNAIL, @@ -732,6 +739,7 @@ export class DotBlockEditorComponent implements OnInit, OnChanges, OnDestroy, Co */ private getEditorMarks() { return [ + UnsupportedBlockMark, Underline, TextAlign.configure({ types: ['heading', 'paragraph', 'listItem', 'dotImage'] }), Highlight.configure({ HTMLAttributes: { style: 'background: #accef7;' } }), @@ -770,11 +778,20 @@ export class DotBlockEditorComponent implements OnInit, OnChanges, OnDestroy, Co return; } + // Nodes first, then marks: an unknown node is swallowed whole into the placeholder's + // `originalNode` payload, which must stay exactly as stored, so the mark pass only + // ever walks what is left of the real tree. const preservedContent = Array.isArray(content) - ? preserveUnknownBlockNodes(content, this.#knownEditorNodeNames) + ? preserveUnknownBlockMarks( + preserveUnknownBlockNodes(content, this.#knownEditorNodeNames), + this.#knownEditorMarkNames + ) : { ...content, - content: preserveUnknownBlockNodes(content.content, this.#knownEditorNodeNames) + content: preserveUnknownBlockMarks( + preserveUnknownBlockNodes(content.content, this.#knownEditorNodeNames), + this.#knownEditorMarkNames + ) }; this.content = diff --git a/core-web/libs/block-editor/src/lib/nodes/index.ts b/core-web/libs/block-editor/src/lib/nodes/index.ts index 4a4c14645b7d..9d4d9f6647cb 100644 --- a/core-web/libs/block-editor/src/lib/nodes/index.ts +++ b/core-web/libs/block-editor/src/lib/nodes/index.ts @@ -7,3 +7,4 @@ export * from './ai-content/ai-content.node'; export * from './loader/loader.node'; export * from './grid-block'; export * from './unsupported-block/unsupported-block.node'; +export * from './unsupported-block/unsupported-block.mark'; diff --git a/core-web/libs/block-editor/src/lib/nodes/unsupported-block/unsupported-block.mark.ts b/core-web/libs/block-editor/src/lib/nodes/unsupported-block/unsupported-block.mark.ts new file mode 100644 index 000000000000..57a3a2fa37da --- /dev/null +++ b/core-web/libs/block-editor/src/lib/nodes/unsupported-block/unsupported-block.mark.ts @@ -0,0 +1,7 @@ +import { createUnsupportedBlockMark } from '@dotcms/dotcms-models'; + +/** + * Mark-side half of `UnsupportedBlockNode`: preserves a mark this schema does not declare + * so it round-trips instead of aborting `Node.fromJSON` for the whole document (#37175). + */ +export const UnsupportedBlockMark = createUnsupportedBlockMark(); diff --git a/core-web/libs/block-editor/src/lib/shared/utils/unknown-block.utils.ts b/core-web/libs/block-editor/src/lib/shared/utils/unknown-block.utils.ts index ee309572849f..a5513d6b3bee 100644 --- a/core-web/libs/block-editor/src/lib/shared/utils/unknown-block.utils.ts +++ b/core-web/libs/block-editor/src/lib/shared/utils/unknown-block.utils.ts @@ -1,8 +1,13 @@ export { + createUnknownBlockMarkAttrs, createUnknownBlockNodeAttrs, + parseUnknownBlockOriginalMark, parseUnknownBlockOriginalNode, + preserveUnknownBlockMarks, preserveUnknownBlockNodes, + renderUnknownBlockOriginalMark, renderUnknownBlockOriginalNode, restoreUnknownBlockNodes, + UNKNOWN_BLOCK_MARK_NAME, UNKNOWN_BLOCK_NODE_NAME } from '@dotcms/dotcms-models'; diff --git a/core-web/libs/dotcms-models/src/lib/unknown-block.util.ts b/core-web/libs/dotcms-models/src/lib/unknown-block.util.ts index 7299448cecc1..919b878fac55 100644 --- a/core-web/libs/dotcms-models/src/lib/unknown-block.util.ts +++ b/core-web/libs/dotcms-models/src/lib/unknown-block.util.ts @@ -1,16 +1,26 @@ -import { JSONContent, Node } from '@tiptap/core'; +import { JSONContent, Mark, Node } from '@tiptap/core'; export const UNKNOWN_BLOCK_NODE_NAME = 'dotUnsupportedBlock'; +export const UNKNOWN_BLOCK_MARK_NAME = 'dotUnsupportedMark'; type JSONLike = JSONContent | JSONContent[]; type JSONLikeOrUndefined = JSONLike | undefined; +/** The JSON shape TipTap uses for an entry of `JSONContent.marks`. */ +type JSONMark = NonNullable[number]; + export interface UnknownBlockNodeAttrs { originalType: string | null; originalNode: JSONContent | null; originalNodeRaw: string | null; } +export interface UnknownBlockMarkAttrs { + originalType: string | null; + originalMark: JSONMark | null; + originalMarkRaw: string | null; +} + function isJsonContent(value: unknown): value is JSONContent { const type = (value as JSONContent | null | undefined)?.type; @@ -23,6 +33,18 @@ function isJsonContent(value: unknown): value is JSONContent { ); } +/** + * Mark-side counterpart of {@link isJsonContent}. A mark JSON shares the same + * "object carrying a non-empty string `type`" shape, so this delegates — but it is a + * separate name on purpose. `isJsonContent` is free to tighten around node-specific rules + * (requiring `content`, or checking the name against the schema's nodes), and mark + * restoration must not start failing when it does. That failure would be silent: the symptom + * is a `dotUnsupportedMark` written to storage, not an exception. + */ +function isJsonMark(value: unknown): value is JSONMark { + return isJsonContent(value); +} + /** * Builds the placeholder attrs stored on `dotUnsupportedBlock`. * @@ -42,6 +64,21 @@ export function createUnknownBlockNodeAttrs( }; } +/** + * Builds the placeholder attrs stored on `dotUnsupportedMark` — the mark-side + * counterpart of {@link createUnknownBlockNodeAttrs}. + */ +export function createUnknownBlockMarkAttrs( + mark: JSONMark, + markType: string | null +): UnknownBlockMarkAttrs { + return { + originalType: markType, + originalMark: mark, + originalMarkRaw: null + }; +} + /** * Parses the serialized `data-original-node` HTML attribute back into JSON when * possible, and preserves the raw string separately when parsing fails so the @@ -72,6 +109,32 @@ export function parseUnknownBlockOriginalNode( } } +/** Mark-side counterpart of {@link parseUnknownBlockOriginalNode}. */ +export function parseUnknownBlockOriginalMark( + value: string | null +): Pick { + if (!value) { + return { + originalMark: null, + originalMarkRaw: null + }; + } + + try { + return { + originalMark: JSON.parse(value), + originalMarkRaw: null + }; + } catch (error) { + console.warn('[unsupported-mark] failed to parse originalMark', error); + + return { + originalMark: null, + originalMarkRaw: value + }; + } +} + /** * Re-renders the preserved original node payload back onto the placeholder DOM * node so unsupported blocks keep their recoverable serialized representation. @@ -88,6 +151,19 @@ export function renderUnknownBlockOriginalNode( : {}; } +/** Mark-side counterpart of {@link renderUnknownBlockOriginalNode}. */ +export function renderUnknownBlockOriginalMark( + attributes: Partial +): Record { + if (isJsonMark(attributes.originalMark)) { + return { 'data-original-mark': JSON.stringify(attributes.originalMark) }; + } + + return typeof attributes.originalMarkRaw === 'string' && attributes.originalMarkRaw.length > 0 + ? { 'data-original-mark': attributes.originalMarkRaw } + : {}; +} + export function createUnsupportedBlockNode() { return Node.create({ name: UNKNOWN_BLOCK_NODE_NAME, @@ -144,6 +220,70 @@ export function createUnsupportedBlockNode() { }); } +/** + * Placeholder for a mark the current schema does not know, and the reason unknown marks + * no longer blank the whole field (#37175). + * + * Unlike an unknown *node*, an unknown *mark* has nothing to show: the text it decorates + * is ordinary text that must keep rendering and stay editable. So this mark is visually + * neutral — it only carries the original mark payload so it round-trips back on save + * instead of being dropped. + * + * `inclusive: false` keeps the mark from swallowing text typed at its boundary, so an + * author extending that sentence does not silently widen a mark this editor cannot render. + * + * KNOWN LIMITATION — "Clear formatting" discards the preserved payload. That action runs + * `unsetAllMarks().clearNodes()` (`toolbar.component.ts`; the legacy bubble menu runs + * `unsetAllMarks()`), which strips this placeholder along with every real mark — and + * `clearNodes()` does the same to `dotUnsupportedBlock`. Since neither placeholder renders + * any visible formatting, the author has no way to know something was discarded, and there + * is no way back. It is still strictly better than the pre-fix behaviour, where the document + * did not load at all, and unlike that, it is user-initiated. Excluding BOTH placeholders + * from the clear-formatting command is worth doing, but it belongs to that command in each + * editor rather than here — the node half is affected the same way and predates this mark. + */ +export function createUnsupportedBlockMark() { + return Mark.create({ + name: UNKNOWN_BLOCK_MARK_NAME, + inclusive: false, + + addAttributes() { + return { + originalType: { + default: null, + parseHTML: (element) => element.getAttribute('data-original-type'), + renderHTML: (attributes) => + attributes['originalType'] + ? { 'data-original-type': attributes['originalType'] } + : {} + }, + originalMark: { + default: null, + parseHTML: (element) => + parseUnknownBlockOriginalMark(element.getAttribute('data-original-mark')) + .originalMark, + renderHTML: (attributes) => renderUnknownBlockOriginalMark(attributes) + }, + originalMarkRaw: { + default: null, + parseHTML: (element) => + parseUnknownBlockOriginalMark(element.getAttribute('data-original-mark')) + .originalMarkRaw, + renderHTML: () => ({}) + } + }; + }, + + parseHTML() { + return [{ tag: `span[data-mark-type="${UNKNOWN_BLOCK_MARK_NAME}"]` }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['span', { ...HTMLAttributes, 'data-mark-type': UNKNOWN_BLOCK_MARK_NAME }, 0]; + } + }); +} + /** * Replaces any node whose type is unknown to the current schema with the shared * unsupported-block placeholder while recursively preserving unknown descendants @@ -180,6 +320,77 @@ export function preserveUnknownBlockNodes( return content.map((node) => replaceUnknownNode(node, knownNodeNames)) as T; } +/** Swaps a single mark for the placeholder when the schema does not declare it. */ +function replaceUnknownMark(mark: JSONMark, knownMarkNames: Set): JSONMark { + const markType = typeof mark?.type === 'string' ? mark.type : null; + + if (markType && knownMarkNames.has(markType)) { + return mark; + } + + return { + type: UNKNOWN_BLOCK_MARK_NAME, + attrs: createUnknownBlockMarkAttrs(mark, markType) + }; +} + +function replaceUnknownMarksInNode(node: JSONContent, knownMarkNames: Set): JSONContent { + // A `dotUnsupportedBlock` placeholder already holds its whole original payload — + // marks included — in `attrs.originalNode`. That payload is inert data rather than + // part of the document tree, so it has to be left byte-for-byte as stored. + if (node.type === UNKNOWN_BLOCK_NODE_NAME) { + return node; + } + + return { + ...node, + marks: Array.isArray(node.marks) + ? node.marks.map((mark) => replaceUnknownMark(mark, knownMarkNames)) + : node.marks, + content: preserveUnknownBlockMarks(node.content, knownMarkNames) + }; +} + +/** + * Replaces every mark the schema does not declare with the `dotUnsupportedMark` + * placeholder, so `Node.fromJSON` never meets a mark type it cannot resolve. + * + * This is the mark-side half of {@link preserveUnknownBlockNodes} and must run *after* + * it: nodes first, so content already swallowed into a `dotUnsupportedBlock` payload is + * not rewritten, then marks over what remains of the real tree. + * + * Without this an unknown mark threw `RangeError: There is no mark type X in this schema` + * mid-recursion, aborting `Node.fromJSON` for the ENTIRE document. TipTap catches that, + * warns, and boots an empty document — so the field looked emptied while the stored JSON + * was intact, and the next save made the loss real (#37175). + */ +export function preserveUnknownBlockMarks( + content: T, + knownMarkNames: Set +): T { + if (!content) { + return content; + } + + if (!Array.isArray(content)) { + return replaceUnknownMarksInNode(content, knownMarkNames) as T; + } + + return content.map((node) => replaceUnknownMarksInNode(node, knownMarkNames)) as T; +} + +/** + * Restores a placeholder mark back to the mark it stood in for, keeping the placeholder + * when the payload is no longer valid so the recoverable raw string still round-trips. + */ +function restoreUnknownMark(mark: JSONMark): JSONMark { + if (mark?.type === UNKNOWN_BLOCK_MARK_NAME && isJsonMark(mark.attrs?.['originalMark'])) { + return mark.attrs['originalMark'] as JSONMark; + } + + return mark; +} + /** * Restores a placeholder node back to its original JSON only when the preserved * payload is still a valid TipTap node; otherwise the placeholder is kept so the @@ -192,10 +403,16 @@ function restoreUnknownBlockNode(node: JSONContent): JSONContent { return { ...node, + marks: Array.isArray(node.marks) ? node.marks.map(restoreUnknownMark) : node.marks, content: restoreUnknownBlockNodes(node.content) }; } +/** + * Inverse of {@link preserveUnknownBlockNodes} + {@link preserveUnknownBlockMarks}: turns + * both placeholders back into the payloads they preserved, so what gets saved is the + * content that was loaded plus the author's edits — never the placeholders themselves. + */ export function restoreUnknownBlockNodes(content: T): T { if (!content) { return content; diff --git a/core-web/libs/new-block-editor/CLAUDE.md b/core-web/libs/new-block-editor/CLAUDE.md index 3d50ae8bd696..e6fe93c96d59 100644 --- a/core-web/libs/new-block-editor/CLAUDE.md +++ b/core-web/libs/new-block-editor/CLAUDE.md @@ -186,6 +186,42 @@ What actions are available on each node type. **Slash** = appears in `/` menu (` | `highlight` | `@tiptap/extension-highlight` | — (schema only; the legacy editor has no button either) | any text | | `link` | `@tiptap/extension-link` | Link popover — hidden when `link` is not in allowed blocks | any text. Always in the schema; `allowedBlocks` gates only the authoring paths (button, autolink, link-on-paste). Gating the *registration* blanked every restricted field (#37175) — a missing mark aborts `Node.fromJSON` for the whole document, and `link` is not even offered as an Allowed Blocks option. | | `textAlign` | `@tiptap/extension-text-align` | Align L/C/R/Justify | configured for `paragraph` + `heading` only | +| `dotUnsupportedMark` | `createUnsupportedBlockMark()` (`@dotcms/dotcms-models`) | — (never authored; visually neutral) | any text whose stored mark this schema does not declare. Registered unconditionally: an unknown mark aborts `Node.fromJSON` for the WHOLE document, so `preserveUnknownBlockMarks` swaps it for this placeholder on load and `restoreUnknownBlockNodes` puts the original back on save (#37175). Mark-side twin of `dotUnsupportedBlock`. | + +### Unknown nodes and marks (load-path invariant) + +Stored content can name a node or mark this schema does not declare — content written by the +API, migrated from another CMS, or produced by a newer version. Both are preserved rather than +dropped, and the two passes are **not** interchangeable: + +| | Unknown node | Unknown mark | +|---|---|---| +| Placeholder | `dotUnsupportedBlock` — renders `Unsupported block (type)` | `dotUnsupportedMark` — visually neutral, the text renders as ordinary text | +| Preserve on load | `preserveUnknownBlockNodes` | `preserveUnknownBlockMarks` | +| Restore on save | `restoreUnknownBlockNodes` (handles both) | same | +| Failure without it | `RangeError: Unknown node type: X` | `RangeError: There is no mark type X in this schema` | + +Order matters: **nodes first, then marks.** An unknown node is swallowed whole into the +placeholder's `originalNode` attr, and that payload is inert data rather than part of the +document tree — running the mark pass first would rewrite marks inside the very payload the +placeholder exists to preserve. + +Both editors wire this the same way (`preserveUnknownNodesInDocument` here, +`setEditorJSONContent` in the legacy `dot-block-editor.component.ts`), and both derive the known +sets from the live schema, so a newly registered extension needs no bookkeeping. + +TipTap does not surface either failure loudly: it catches the `RangeError`, logs +`[tiptap warn]: Invalid content`, and boots an EMPTY document. The field looks emptied while the +stored JSON is intact — and the next save makes that loss real (#37145, #37175). + +**Known limitation — "Clear formatting" discards both payloads.** That action runs +`unsetAllMarks().clearNodes()` (`toolbar.component.ts`; the legacy bubble menu runs +`unsetAllMarks()`), which strips `dotUnsupportedMark` along with every real mark, and +`clearNodes()` does the same to `dotUnsupportedBlock`. Neither placeholder renders visible +formatting, so the author cannot tell anything was discarded and there is no way back. Still +strictly better than not loading the document at all, and unlike that, user-initiated. +Excluding both placeholders from the clear-formatting command is worth doing — it belongs to +that command in each editor, not to the placeholders. ### Special / node-scoped commands diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts b/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts index 50f52051ce89..56e4c518015b 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts @@ -133,6 +133,10 @@ function getKnownNodeNames(editor: Editor): Set { return new Set(Object.keys(editor.schema.nodes)); } +function getKnownMarkNames(editor: Editor): Set { + return new Set(Object.keys(editor.schema.marks)); +} + /** True when {@link parsed} represents the same document already in {@link editor}. */ function editorContentMatchesParsed(editor: Editor, parsed: string | JSONContent): boolean { const currentJson = editorDocumentJsonText(editor); @@ -149,7 +153,13 @@ function editorContentMatchesParsed(editor: Editor, parsed: string | JSONContent } return ( JSON.stringify( - stripDocStats(preserveUnknownNodesInDocument(parsed, getKnownNodeNames(editor))) + stripDocStats( + preserveUnknownNodesInDocument( + parsed, + getKnownNodeNames(editor), + getKnownMarkNames(editor) + ) + ) ) === currentJson ); } @@ -499,7 +509,11 @@ export class DotCMSEditorComponent implements OnInit, OnDestroy, ControlValueAcc editor.commands.setContent( typeof parsed === 'string' ? parsed - : preserveUnknownNodesInDocument(parsed, getKnownNodeNames(editor)), + : preserveUnknownNodesInDocument( + parsed, + getKnownNodeNames(editor), + getKnownMarkNames(editor) + ), { emitUpdate: false } ); } @@ -626,7 +640,11 @@ export class DotCMSEditorComponent implements OnInit, OnDestroy, ControlValueAcc ed.commands.setContent( typeof parsed === 'string' ? parsed - : preserveUnknownNodesInDocument(parsed, getKnownNodeNames(ed)), + : preserveUnknownNodesInDocument( + parsed, + getKnownNodeNames(ed), + getKnownMarkNames(ed) + ), { emitUpdate: false } ); }); @@ -748,7 +766,11 @@ export class DotCMSEditorComponent implements OnInit, OnDestroy, ControlValueAcc ed.commands.setContent( typeof parsed === 'string' ? parsed - : preserveUnknownNodesInDocument(parsed, getKnownNodeNames(ed)), + : preserveUnknownNodesInDocument( + parsed, + getKnownNodeNames(ed), + getKnownMarkNames(ed) + ), { emitUpdate: false } ); } diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.spec.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.spec.ts index ddc757da6536..9fbf8419ce15 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.spec.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.spec.ts @@ -6,8 +6,13 @@ import { Node as PMNode } from '@tiptap/pm/model'; import type { DotMessageService } from '@dotcms/data-access'; import { createEditorExtensions } from './editor-extensions'; +import { DotLink } from './link.extension'; -import { UNKNOWN_BLOCK_NODE_NAME } from '../utils/unknown-block.utils'; +import { + preserveUnknownNodesInDocument, + UNKNOWN_BLOCK_MARK_NAME, + UNKNOWN_BLOCK_NODE_NAME +} from '../utils/unknown-block.utils'; import type { SlashMenuService } from '../components/slash-menu/slash-menu.service'; @@ -188,4 +193,137 @@ describe('createEditorExtensions', () => { expect(byName(extensions, 'emoji')?.options.enableEmoticons).toBe(true); }); }); + + /** + * #37175 AC5 — the failure mode the two registered marks only papered over. Any mark the + * schema does not declare aborts `Node.fromJSON` for the WHOLE document, so registering + * `link` and `highlight` fixed the two known offenders, not the class of bug. The realistic + * sources are content that did not come from this editor: an API write, a migration from + * another CMS (`textStyle`, `color`, `fontFamily` are the usual suspects), or a version + * downgrade. + */ + describe('unknown marks no longer abort the document (#37175 AC5)', () => { + const RESTRICTED = ['bulletList', 'orderedList', 'codeBlock']; + + const schema = () => + getSchema(createEditorExtensions(menuService, RESTRICTED, injector, messageService)); + + /** Two paragraphs so a partial load is distinguishable from a total abort. */ + const storedDoc = (mark: Record) => ({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', marks: [mark], text: 'imported copy' }] + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'plain sibling' }] + } + ] + }); + + const knownNames = (target: ReturnType) => ({ + nodes: new Set(Object.keys(target.nodes)), + marks: new Set(Object.keys(target.marks)) + }); + + it('registers the unsupported-mark placeholder', () => { + expect(Object.keys(schema().marks)).toContain(UNKNOWN_BLOCK_MARK_NAME); + }); + + it('is the exact throw the fix has to prevent', () => { + expect(() => PMNode.fromJSON(schema(), storedDoc({ type: 'textStyle' }))).toThrow( + /no mark type textStyle/ + ); + }); + + it('loads the whole document once the unknown mark is preserved', () => { + const target = schema(); + const { nodes, marks } = knownNames(target); + + const doc = PMNode.fromJSON( + target, + preserveUnknownNodesInDocument( + storedDoc({ type: 'textStyle', attrs: { color: '#ff0000' } }), + nodes, + marks + ) + ); + + // Before the fix this was an empty doc: 0 characters, both paragraphs gone. + expect(doc.childCount).toBe(2); + expect(doc.textContent).toBe('imported copyplain sibling'); + }); + + it('keeps the decorated text editable, carrying the payload for the save path', () => { + const target = schema(); + const { nodes, marks } = knownNames(target); + const original = { type: 'textStyle', attrs: { color: '#ff0000' } }; + + const doc = PMNode.fromJSON( + target, + preserveUnknownNodesInDocument(storedDoc(original), nodes, marks) + ); + const [mark] = doc.firstChild?.firstChild?.marks ?? []; + + expect(mark.type.name).toBe(UNKNOWN_BLOCK_MARK_NAME); + expect(mark.attrs['originalMark']).toEqual(original); + }); + + it('survives a mark with no attrs at all', () => { + const target = schema(); + const { nodes, marks } = knownNames(target); + + const doc = PMNode.fromJSON( + target, + preserveUnknownNodesInDocument(storedDoc({ type: 'someUnknownMark' }), nodes, marks) + ); + + expect(doc.textContent).toBe('imported copyplain sibling'); + }); + }); + + /** + * #37175 AC3 — `linkOnPaste: false` alone did not close the link-on-paste path: TipTap's + * Link returns its URL paste rule ungated, so pasting text containing a URL still created + * a link mark on a field where `link` is not allowed. `DotLink` overrides `addPasteRules`. + */ + describe('link-on-paste follows the authoring gate (#37175 AC3)', () => { + const pasteRulesFor = (allowedBlocks: string[] | undefined) => { + const link = flattenExtensions( + createEditorExtensions(menuService, allowedBlocks, injector, messageService) + ).find((ext) => ext.name === 'link'); + + return link?.config.addPasteRules?.call({ + options: link.options, + parent: () => [{ find: /url/, handler: () => undefined }] + }); + }; + + it('drops the URL paste rule when link is not an allowed block', () => { + expect(pasteRulesFor(['bulletList', 'orderedList'])).toEqual([]); + }); + + it('keeps the URL paste rule on an unrestricted field', () => { + expect(pasteRulesFor(undefined)).toHaveLength(1); + }); + + /** + * The rule being suppressed is the auto-link-on-paste rule, so it follows `autolink` + * too — not `linkOnPaste` alone. `createEditorExtensions()` sets both from the same + * `has('link')` and cannot produce this combination, which is exactly why it needs + * pinning: nothing else would catch the gate silently killing the linkifying. + */ + it('keeps the URL paste rule when only autolink is enabled', () => { + const link = DotLink.configure({ autolink: true, linkOnPaste: false }); + + expect( + link.config.addPasteRules?.call({ + options: link.options, + parent: () => [{ find: /url/, handler: () => undefined }] + }) + ).toHaveLength(1); + }); + }); }); diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts index 65cb5609115a..31f0bb18a443 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts @@ -34,6 +34,7 @@ import { SelectionPreserveExtension } from './selection-preserve.extension'; import { createSlashCommandExtension } from './slash-command.extension'; import { TableActiveCellsPlugin } from './table-active-cells.plugin'; import { createDotTableExtensions } from './table-extensions'; +import { UnsupportedMark } from './unsupported-mark.extension'; import { EditorPopoverService } from '../services/editor-popover.service'; @@ -96,6 +97,7 @@ export function createEditorExtensions( horizontalRule: has('horizontalRule') ? {} : false }), UnsupportedBlock, + UnsupportedMark, ...(has('codeBlock') ? [createCodeBlock(injector, lowlight)] : []), createBlockGutterDragHandle(t('dot.block.editor.gutter.add-block')), CharacterCount, diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/link.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/link.extension.ts index bdc740ddd1cb..f0a286cd6c0d 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/extensions/link.extension.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/link.extension.ts @@ -21,5 +21,28 @@ export const DotLink = Link.extend({ attrs['aria-label'] ? { 'aria-label': attrs['aria-label'] } : {} } }; + }, + + /** + * The base extension returns its URL paste rule unconditionally — `linkOnPaste` only + * governs wrapping the *selection*, so pasting text that merely contains a URL still + * created a link mark on a field where `link` is not an allowed block (#37175). + * + * The mark stays in the schema either way (dropping it aborts `Node.fromJSON` for the + * whole document); what `allowedBlocks` gates is authoring, and auto-linking pasted text + * is an authoring path. Compare `@tiptap/extension-youtube`, which guards its own paste + * handler with `addPasteHandler`. + * + * Gated on EITHER flag rather than on `linkOnPaste` alone: the rule being suppressed is + * the auto-link-on-paste rule, which sits closer to `autolink` than to `linkOnPaste`. + * `createEditorExtensions()` derives both from the same `has('link')`, so today they + * cannot disagree — but `linkOnPaste: false` + `autolink: true` is a coherent + * configuration ("don't wrap my selection, but do linkify what I paste") and must not + * silently lose the linkifying. + */ + addPasteRules() { + const authoringAllowsLinks = this.options.autolink || this.options.linkOnPaste; + + return authoringAllowsLinks ? (this.parent?.() ?? []) : []; } }); diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/unsupported-mark.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/unsupported-mark.extension.ts new file mode 100644 index 000000000000..a0bb7ce19966 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/unsupported-mark.extension.ts @@ -0,0 +1,7 @@ +import { createUnsupportedBlockMark } from '@dotcms/dotcms-models'; + +/** + * Registered unconditionally so a mark this schema does not declare degrades to a neutral + * placeholder instead of aborting `Node.fromJSON` for the whole document (#37175). + */ +export const UnsupportedMark = createUnsupportedBlockMark(); diff --git a/core-web/libs/new-block-editor/src/lib/editor/utils/unknown-block.utils.spec.ts b/core-web/libs/new-block-editor/src/lib/editor/utils/unknown-block.utils.spec.ts index db7d7767a15c..9aed5d7683a7 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/utils/unknown-block.utils.spec.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/utils/unknown-block.utils.spec.ts @@ -1,9 +1,11 @@ import { JSONContent } from '@tiptap/core'; import { + preserveUnknownBlockMarks, preserveUnknownBlockNodes, preserveUnknownNodesInDocument, restoreUnknownBlockNodes, + UNKNOWN_BLOCK_MARK_NAME, UNKNOWN_BLOCK_NODE_NAME } from './unknown-block.utils'; @@ -123,6 +125,7 @@ describe('unknown-block.utils', () => { */ describe('preserveUnknownNodesInDocument', () => { const known = new Set(['doc', 'paragraph', 'text']); + const knownMarks = new Set(['bold', 'link']); it('keeps a bare array of nodes as an array', () => { const input: JSONContent[] = [ @@ -130,7 +133,7 @@ describe('preserveUnknownNodesInDocument', () => { { type: 'paragraph', content: [{ type: 'text', text: 'second' }] } ]; - const result = preserveUnknownNodesInDocument(input, known); + const result = preserveUnknownNodesInDocument(input, known, knownMarks); expect(Array.isArray(result)).toBe(true); expect(result).toEqual(input); @@ -139,7 +142,7 @@ describe('preserveUnknownNodesInDocument', () => { it('never turns an array into a plain object carrying an undefined content', () => { const input: JSONContent[] = [{ type: 'paragraph' }]; - const result = preserveUnknownNodesInDocument(input, known); + const result = preserveUnknownNodesInDocument(input, known, knownMarks); // The spread bug produced `{ 0: node, content: undefined }`: still an object, and the // stray `content` key is what made TipTap throw `Unknown node type: undefined`. @@ -150,7 +153,11 @@ describe('preserveUnknownNodesInDocument', () => { it('still preserves unknown nodes inside a bare array', () => { const unknown: JSONContent = { type: 'customGallery', attrs: { layout: 'single' } }; - const result = preserveUnknownNodesInDocument([unknown], known) as JSONContent[]; + const result = preserveUnknownNodesInDocument( + [unknown], + known, + knownMarks + ) as JSONContent[]; expect(result[0].type).toBe(UNKNOWN_BLOCK_NODE_NAME); expect(result[0].attrs).toEqual({ @@ -166,7 +173,7 @@ describe('preserveUnknownNodesInDocument', () => { content: [{ type: 'paragraph', content: [{ type: 'text', text: 'keep me' }] }] }; - expect(preserveUnknownNodesInDocument(doc, known)).toEqual(doc); + expect(preserveUnknownNodesInDocument(doc, known, knownMarks)).toEqual(doc); }); it('preserves sibling document attrs such as the doc stats', () => { @@ -176,6 +183,152 @@ describe('preserveUnknownNodesInDocument', () => { content: [{ type: 'paragraph' }] }; - expect(preserveUnknownNodesInDocument(doc, known)).toEqual(doc); + expect(preserveUnknownNodesInDocument(doc, known, knownMarks)).toEqual(doc); + }); +}); + +/** + * #37175 AC5 — an unknown *mark* used to abort `Node.fromJSON` for the WHOLE document: + * `RangeError: There is no mark type X in this schema`. TipTap catches that, warns, and + * boots an empty document, so the field looked emptied while the stored JSON was intact — + * and the next save made the loss real. Unknown *nodes* already degraded to a placeholder; + * marks had no equivalent until `dotUnsupportedMark`. + */ +describe('preserveUnknownBlockMarks', () => { + const knownMarks = new Set(['bold', 'link']); + + it('round-trips an unregistered mark through the placeholder, keeping the text', () => { + const unknownMark = { type: 'textStyle', attrs: { color: '#ff0000' } }; + const input: JSONContent[] = [ + { + type: 'paragraph', + content: [{ type: 'text', marks: [unknownMark], text: 'imported copy' }] + } + ]; + + const preserved = preserveUnknownBlockMarks(input, knownMarks); + + expect(preserved).toEqual([ + { + type: 'paragraph', + content: [ + { + type: 'text', + marks: [ + { + type: UNKNOWN_BLOCK_MARK_NAME, + attrs: { + originalType: 'textStyle', + originalMark: unknownMark, + originalMarkRaw: null + } + } + ], + text: 'imported copy' + } + ] + } + ]); + expect(restoreUnknownBlockNodes(preserved)).toEqual(input); + }); + + it('leaves known marks untouched', () => { + const input: JSONContent[] = [ + { + type: 'paragraph', + content: [ + { + type: 'text', + marks: [{ type: 'link', attrs: { href: 'https://dotcms.com' } }], + text: 'keep me a link' + } + ] + } + ]; + + expect(preserveUnknownBlockMarks(input, knownMarks)).toEqual(input); + }); + + it('replaces only the unknown entries of a mixed mark list, in order', () => { + const input: JSONContent[] = [ + { + type: 'paragraph', + content: [ + { + type: 'text', + marks: [{ type: 'bold' }, { type: 'fontFamily' }], + text: 'mixed' + } + ] + } + ]; + + const marks = (preserveUnknownBlockMarks(input, knownMarks) as JSONContent[])[0] + .content?.[0].marks; + + expect(marks?.map((mark) => mark.type)).toEqual(['bold', UNKNOWN_BLOCK_MARK_NAME]); + }); + + it('wraps a mark with a missing or non-string type', () => { + const input = [ + { type: 'paragraph', content: [{ type: 'text', marks: [{}], text: 'x' }] } + ] as JSONContent[]; + + const marks = (preserveUnknownBlockMarks(input, knownMarks) as JSONContent[])[0] + .content?.[0].marks; + + expect(marks?.[0]).toEqual({ + type: UNKNOWN_BLOCK_MARK_NAME, + attrs: { originalType: null, originalMark: {}, originalMarkRaw: null } + }); + }); + + /** + * The ordering invariant between the two passes: nodes run first, and a + * `dotUnsupportedBlock` payload is inert data rather than part of the tree. Rewriting + * marks inside it would corrupt exactly what the placeholder exists to preserve. + */ + it('never rewrites marks inside an unsupported-block payload', () => { + const input: JSONContent[] = [ + { + type: UNKNOWN_BLOCK_NODE_NAME, + attrs: { + originalType: 'customGallery', + originalNode: { + type: 'customGallery', + content: [{ type: 'text', marks: [{ type: 'fontFamily' }], text: 'x' }] + }, + originalNodeRaw: null + } + } + ]; + + expect(preserveUnknownBlockMarks(input, knownMarks)).toEqual(input); + }); + + it('keeps a placeholder whose payload is no longer valid during restore', () => { + const input: JSONContent[] = [ + { + type: 'paragraph', + content: [ + { + type: 'text', + marks: [ + { + type: UNKNOWN_BLOCK_MARK_NAME, + attrs: { + originalType: 'textStyle', + originalMark: null, + originalMarkRaw: '{"type":"textStyle"' + } + } + ], + text: 'corrupted' + } + ] + } + ]; + + expect(restoreUnknownBlockNodes(input)).toEqual(input); }); }); diff --git a/core-web/libs/new-block-editor/src/lib/editor/utils/unknown-block.utils.ts b/core-web/libs/new-block-editor/src/lib/editor/utils/unknown-block.utils.ts index 52d851b7aba6..e4c6f5030bb0 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/utils/unknown-block.utils.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/utils/unknown-block.utils.ts @@ -1,20 +1,30 @@ import type { JSONContent } from '@tiptap/core'; -import { preserveUnknownBlockNodes } from '@dotcms/dotcms-models'; +import { preserveUnknownBlockMarks, preserveUnknownBlockNodes } from '@dotcms/dotcms-models'; export { + createUnknownBlockMarkAttrs, createUnknownBlockNodeAttrs, + parseUnknownBlockOriginalMark, parseUnknownBlockOriginalNode, + preserveUnknownBlockMarks, preserveUnknownBlockNodes, + renderUnknownBlockOriginalMark, renderUnknownBlockOriginalNode, restoreUnknownBlockNodes, + UNKNOWN_BLOCK_MARK_NAME, UNKNOWN_BLOCK_NODE_NAME } from '@dotcms/dotcms-models'; /** - * Replaces unknown nodes with the `dotUnsupportedBlock` placeholder, for either shape a - * Block Editor value can arrive in: a `{ type: 'doc', content: [...] }` document, or a bare - * array of nodes — which is what some hosts pass, notably the UVE side panel. + * Replaces unknown nodes with the `dotUnsupportedBlock` placeholder and unknown marks with + * the `dotUnsupportedMark` placeholder, for either shape a Block Editor value can arrive in: + * a `{ type: 'doc', content: [...] }` document, or a bare array of nodes — which is what some + * hosts pass, notably the UVE side panel. + * + * Nodes are processed before marks on purpose: an unknown node is swallowed whole into the + * placeholder's `originalNode` payload, which must stay byte-for-byte as stored, so the mark + * pass only ever walks what is left of the real tree. * * The array branch is not cosmetic. Spreading an array into an object yields * `{ 0: node, 1: node, ..., content: undefined }`, dropping the document `type` and making @@ -24,14 +34,21 @@ export { */ export function preserveUnknownNodesInDocument( parsed: JSONContent | JSONContent[], - knownNodeNames: Set + knownNodeNames: Set, + knownMarkNames: Set ): JSONContent | JSONContent[] { if (Array.isArray(parsed)) { - return preserveUnknownBlockNodes(parsed, knownNodeNames); + return preserveUnknownBlockMarks( + preserveUnknownBlockNodes(parsed, knownNodeNames), + knownMarkNames + ); } return { ...parsed, - content: preserveUnknownBlockNodes(parsed.content, knownNodeNames) + content: preserveUnknownBlockMarks( + preserveUnknownBlockNodes(parsed.content, knownNodeNames), + knownMarkNames + ) }; }