dotCMS / dotCMS/core

Block Editor: enforce the Story Block capability + delivery contract — 6 live defects, incl. silent node deletion and 461 blank published articles

Open
#37,601 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

dotCMS: Block Editor OKR : Customer Support Team : Falcon Type : Defect
Dominant language
Java
Stars
970
Forks
486
Avg merge
3d 33m
Merged PRs (30d)
170

Description

Problem Statement

Ten customer tickets, eight GitHub issues and seven merged PRs across sixteen months all
resolve to one unenforced contract. Support prescribed the same workaround in four of those
tickets — "uncheck everything under Allowed Blocks and save" — and in a fifth closed the ticket
as user error, without anyone noticing it was the same defect wearing five different symptoms.

[!IMPORTANT]
Scope: frontend only. Everything below lives in core-web/**, plus two Velocity
templates
under dotCMS/src/main/webapp/WEB-INF/velocity/:
VM_global_library.vm (#macro(renderMarks), defect D) and
static/storyblock/render.vtl (#macro(renderContentBlock), defect D2 — see D3 for why the
caller has to change too). No Java, no REST contract, no database, no ElasticSearch, no
indexing.
See Out of scope for what was deliberately left out.

The VTL path is in scope on purpose. It is the delivery path for traditional (non-headless)
dotCMS pages. Leaving it out would make invariant I3 pass with one renderer still broken — which
is precisely how this class of defect escaped six times.

The contract, and why it keeps breaking

PR #37539 wrote the rule down in
specs/36351-block-editor-link-setting/contracts/allowed-blocks-capability-keys.md:

A capability may be gated on Allowed Blocks only if the Settings tab can produce its
identifier.
A capability that is consulted but not producible is permanently forbidden the
instant an administrator restricts the field to anything at all — an outcome nobody chose and
nobody can undo. Gating such a capability is always a defect, never a configuration.

It is a markdown file. Nothing enforces it. There is no single source of truth for capability
identifiers — there are six parallel registries that must agree and are never checked against
each other:

# Registry Role Location
1 getEditorBlockOptions() what Settings can write core-web/libs/block-editor/src/lib/shared/utils/suggestion.utils.ts:115
2 has() / store.isAllowed() what the new editor reads core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts:71 + toolbar / slash menu / popovers
3 relatedContent + getBlockMap what the legacy editor reads and deletes with core-web/libs/block-editor/src/lib/shared/utils/parser.utils.ts:57,131
4 Registered marks getEditorMarks() vs createEditorExtensions() both editors
5 Delivery renderers ×4 VTL · React SDK · Vue SDK · Angular SDK (classic) see Defect D
6 Angular SDK semantic renderer a second, independent Angular renderer — the only one that degrades correctly .../dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.html:355

Registry 6 is the important one: the correct behaviour already exists in this repo and was
never propagated to the other four.

The two failure modes

When a key is consulted but not producible, the capability is dropped on every restricted field.
What happens next depends on what was dropped:

Dropped New Block Editor Legacy Block Editor
A mark (link, highlight) TipTap parses the document atomicallyNode.fromJSON aborts → field renders blank over intact stored JSON → a Save on top makes the loss real registers Link/Highlight unconditionally → not affected
A node (emoji, youtube, customGallery, dotAudio) degrades to an Unsupported block placeholder — visible but wrong purifyNodeTree deletes the node from the document at load time → lost on next save

The shape is the same in both: fail closed. One element the code does not fully recognise
discards the whole document instead of degrading the part it cannot render. Defect E shows the
delivery layer doing exactly the same thing to content the editor never authored.

Sixteen months, six rounds, no connection drawn

Every round was triaged as an isolated incident. Most carry Team : Maintenance +
Sprint: Injected — the signature of an unplanned customer escalation.

# Date Issue Trigger Outcome
1 2022-08 → 2023-05 #22164 · #23764 · #24370 Allowed Blocks introduced, then crashes on restriction closed one by one
2 2024-01 #27101 crash on table selection in allowed blocks closed
3 2025-05 #29772 paragraph missing from the options list closed
4 2025-12 #34110 bubble menu ignores allowedBlocks Customer Deployed
5 2026-03 #35032 ← FD #35973 restricting image/video blanks the editor; blocked a customer redesign launch hotfix deployed to the customer
6 2026-06 → 2026-09 #36351 · #37145 · #37175 · #37340 link, highlight, emoji — four ad-hoc ungatings in ten weeks this issue
The family this issue closes
Freshdesk Customer Issue PR Symptom Key at fault
#37852 (+38035, +38092) Muskegon County #36351 #37539 hyperlink button vanished link (authoring gate)
#38349 Methodist Health none hyperlink button vanished — closed as user error, no issue filed link (authoring gate)
#38997 Michigan State University #37175 #37205 → #37313 Block Editor shows no content link (mark registration)
#38993 Worldline / Logeecom #37175 ditto empty contentlet despite content link (mark registration)
#38957 CarFinance247 #37145 #37149 Guide body renders blank highlight (unregistered)
#35973 University of Akron #35032 editor does not render when image/video restricted image/video gate
#39197 Lennox Commercial #37340 #37442 © ® split one link into two anchors emoji (gate + node)
#38460 #36668 custom remote blocks stripped on load customGallery (not producible)
#38931 CITGO none 461 migrated articles render an empty body see Defect E
#36572 #36574 Underline mark + button missing underline (unregistered)

FD #38349 is the one to read. On 2026-07-15 a
customer reported the hyperlink button missing. Support diagnosed "a stray empty value in Allowed
Blocks"
, told them to clear the field, and closed it in two hours with
Github Issue Filed: No. #36351 — the real defect — had been open for sixteen days at that point
and was not fixed until 2026-09-16. The triaging agent's own internal note called it:

"The underlying value of the blank chip is unverified … If the stray value appeared on its
own (e.g. after the recent Block Editor v3 update), that's worth a GitHub issue — ask for
confirmation before filing."

Nobody asked. The same note cites #34110 and #29772 — two prior rounds of this exact class — and
still reaches "configuration". The class does not only get patched symptom by symptom; it also
gets closed as user error.
That is what an invariant test prevents and a markdown contract does
not.

A related root cause, deliberately not in this issue's scope

FD #38740 (Worldline) — embedded contentlets
unselectable — is the same disease on a different axis: not capability keys, but attribute
serialization
. The legacy and new editors write byte-different JSON for the same document, and
#36985 broke because the load path compared byte-wise. PR #37319 measured four divergences
(chartCount typo, absent indent, listItem.textAlign, alphabetical attr key order) and notes
that listItem.textAlign is a live divergence the legacy editor still writes today. It was
fixed structurally there (utils/content-match.utils.ts); the issue was reopened twice before
it held. Recorded here so the next person sees the pattern, not re-litigated here.


Five defects still live on main

A, B, C and D were verified against the working tree at 667fc831ee. D2 and E were verified in
the same tree and are additionally evidenced by customer tickets. None has an open issue.

🔴 A — Legacy editor deletes dotAudio and aiContent nodes (silent data loss)

relatedContent (parser.utils.ts:57) is a hand-maintained alias table mapping Settings
vocabulary
TipTap node names. It has entries for image → dotImage and
video → dotVideo. It has no entry for audio → dotAudio, and none for
aiContentPrompt → aiContent
.

Executing the real logic of parser.utils.ts (imports stripped, constants inlined, functions
otherwise verbatim):

blockMap for [paragraph, audio] : {paragraph,text,doc,hardBreak,dotUnsupportedBlock,audio}

AUDIO  allowed=[paragraph,audio]            -> ['paragraph']             ← dotAudio DELETED
AI     allowed=[paragraph,aiContentPrompt]  -> ['paragraph']             ← aiContent DELETED
IMAGE  allowed=[paragraph,image]  (control) -> ['paragraph','dotImage']  ← survives
VIDEO  allowed=[paragraph,video]  (control) -> ['paragraph','dotVideo']  ← survives

The administrator ticks "Audio" in Allowed Blocks and the editor removes every stored audio
block on open
. This is strictly worse than the ticketed defects: there the stored JSON stayed
intact until someone saved; here the node is gone from the in-memory tree at load and the next
Save makes it permanent.

The load path is dot-block-editor.component.ts:774-805preserveUnknownBlockNodes
preserveUnknownBlockMarksremoveInvalidNodes. A registered node such as dotAudio
survives the preservation pass precisely because it is known, and is then deleted by the filter.

The same _customNodes map (dot-block-editor.component.ts:148-156) is keyed by Settings
vocabulary yet contains aiContent and loader — keys Settings can never emit — so those
extensions are never registered on a restricted field either.

🟠 B — youtube is still gated on a non-producible key
Site Line
core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.ts 225showAssetByUrl()
core-web/libs/new-block-editor/src/lib/editor/components/asset-by-url-popover/asset-by-url-popover.component.ts 83

youtube is not in getEditorBlockOptions(), so isAllowed('youtube') is true only on a
field with no restriction whatsoever. Restricting a field to anything at all silently removes the
YouTube tab from "Add asset by URL". link (#36351) and emoji (#37340) were ungated; this is
the third of the trio from #37175 and was left behind — it is recorded as a known violation,
out of scope
in the capability-keys contract.

🟠 C — aiContent / aiImage: the gate is inverted

core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu-catalog.ts:468,476
consults blockName: 'aiContent' and blockName: 'aiImage'. Settings produces
aiContentPrompt and aiImagePrompt (suggestion.utils.ts:91,96).

The consequence is perverse: ticking "AI Content" in Settings is exactly what hides it from
the slash menu, because the field is then restricted and isAllowed('aiContent') is false. Not
ticking it hides it too. No configuration shows AI Content on a restricted field. Also recorded
as a known violation in the contract.

🔴 D — The delivery layer has the same bug and has never been audited

The new editor's schema declares 10 marks (confirmed in the running app by QA on #37175):
bold, code, dotUnsupportedMark, highlight, italic, link, strike, subscript, superscript, underline. The legacy editor registers the same set. There are five renderers and every one
of them knows 7:

Renderer File Coverage On an unknown mark
Angular SDK (classic) core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/blocks/text.component.ts:125 7/10 @default { {{ text }} }stops recursing
React SDK core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/blocks/Texts.tsx:128 7/10 if (!Component) { return text; }stops recursing
Vue SDK core-web/libs/sdk/vue/src/lib/components/DotCMSBlockEditorRenderer/components/blocks/TextBlock.vue:19-26,39-45 7/10 v-if="tag" → falls to {{ text }}stops recursing
VTL macro dotCMS/src/main/webapp/WEB-INF/velocity/VM_global_library.vm:7 (#macro(renderMarks)) — one implementation, two entry points: the global macro and static/storyblock/render.vtl:97, which delegates to it 7/10 ignores the mark (degrades correctly — flat loop, not recursive)
Angular SDK (semantic) core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.html:355-362 7/10 skips the mark and keeps recursing — correct

Missing in all five: code, highlight, dotUnsupportedMark. Three concrete consequences:

  1. The new editor ships an always-available inline-code button
    (toolbar.component.html:107toggleCode(), ungated). An author applies it, sees it in the
    editor, and the front end does not paint it. Worse: on text carrying marks: [code, bold]
    the React, Vue and classic-Angular renderers also drop the bold, because the unknown mark
    ends the recursion.
  2. dotUnsupportedMark — the placeholder introduced by #37313 — can legitimately reach
    storage
    (documented in that PR's own footnote: when the preserved payload no longer parses,
    restoreUnknownMark keeps the placeholder so the raw payload still round-trips). When it does,
    those three renderers stop walking that node's marks. The fix that prevented editor-side data
    loss opened a formatting-loss path in delivery.
  3. The correct behaviour is already written. The semantic Angular renderer does exactly what
    the other three must do, with the reasoning in a comment
    ("Unknown mark type: skip it but keep rendering any marks beneath."). This is not a design
    question; it is an unpropagated fix.
D2 — adjacent text nodes sharing one link mark still render as two anchors

#37340 declared two defects and said "Both must be fixed — they are separate defects that
happen to surface together here."
Only one was. PR #37442 states its own scope plainly:

"Jest only: no Java, DB, REST, renderer or build artifact is touched."

It solved the emoji split by healing the data in the editor — merging the text nodes on load.
That closes the path for content authored from now on. It does nothing for the Gap B the issue
identified:

  • VTL emits one <a> per text node (VM_global_library.vm:35); the three SDK TextBlock
    components do the same.
  • So any stored JSON containing adjacent text nodes with an identical link mark still renders
    two anchors
    — two tab stops, two entries in the NVDA elements list, WCAG 2.2 A failures
    (1.3.1, 2.4.4, 4.1.2), exactly as FD #39197
    reported.
  • That shape reaches storage from migrations, imports, the legacy editor, the REST API and any
    third-party writer — none of which pass through the new editor's heal.
D3 — Why VTL is the highest-risk of the five renderers, and how to not break it

The other four renderers are TypeScript with a Jest suite behind them. The Velocity path has
neither, so every rule below has to be enforced by an explicit check or it will rot again.

The macro is two mirrored loops, not one. #renderMarks emits every opening tag in forward
order ($range = [$start..$end], VM_global_library.vm:13-44) and every closing tag in reverse
($range = [$end..$start], lines 46-70). A mark added to the opening loop and forgotten in the
closing loop does not "render without the tag" — it produces malformed, unbalanced HTML on
every published page carrying that mark. This is the single most likely way to break production
while fixing D.

$start and $end are set in the first block and reused in the second. They are Velocity
globals with no re-declaration in the closing block. The current code works; anything that moves
or conditionalises the first #if($content.marks) silently breaks the second.

D2 cannot be fixed inside #renderMarks. The macro receives one text node at a time —
it is called from #renderContentBlock at static/storyblock/render.vtl:97, inside the
per-element loop, and that is its only call site in the codebase. Coalescing adjacent nodes that
share a link mark therefore requires a look-ahead in the caller, not in renderMarks. This
is why D2's footprint is two files and D's is one.

There is no .vm test harness in core-web. Do not add a Velocity runtime or a Java
integration test for this — both are out of scope. Enforce the parity textually instead: a
Jest spec that reads the .vm file as a string and asserts that every mark name in the allowlist
appears in both loops with its matching open and close tag. It is a crude check and it is
exactly the right one here: it fails loudly on the one mistake that matters, it needs no runtime,
and it lives in the same suite as I3.

🔴 E — The delivery validator fails closed: one empty node blanks the entire body

isValidBlocks (core-web/libs/sdk/uve/src/lib/editor/internal.ts:65-70) rejects any node whose
content is an empty array, recursively, at any depth:

if (blocks.content.length === 0) {
    return { error: 'Error: Blocks content is empty. At least one block is required.' };
}

All four consuming renderers — React, Vue, Angular classic, Angular semantic — treat that as
fatal and render nothing for the whole field. This is the delivery-side twin of TipTap's
atomic Node.fromJSON: a single unrecognised element discards the entire document.

{"type":"paragraph","content":[]} is a shape the dotCMS editor never emits — it omits the
content key on an empty paragraph. But the editor is not the only writer. In
FD #38931 a Kentico→dotCMS migration translated
<p></p> spacer paragraphs literally, and 461 published articles rendered a completely blank
body
while the page shell, title and hero image loaded normally. Republishing did not help —
the data is the payload. Support's verification is on the ticket: the JSON confirmed via
/api/content/query, the affected count scoped by owner and migration run date (Jul 23: 82,
Aug 12: 103, Aug 14: 276), zero natively-authored articles affected.

The customer's fix was a stripEmptyBlocks sanitiser in their own React code, shipped by
dotCMS engineering as a workaround. The ticket's internal note recorded the obvious follow-up —
"file an SDK graceful-degradation issue — a single empty paragraph node should be skipped, not
blank the entire body"
— and no issue was ever filed. Confirmed: nothing in dotCMS/core
matches the error string or the validator.


Steps to Reproduce

A — Legacy editor deletes allowed audio blocks
  1. Set FEATURE_FLAG_NEW_BLOCK_EDITOR=false.
  2. On a content type with a Block Editor field, open field settings → Allowed Blocks → select
    Audio and Paragraph-adjacent blocks (any list with ≥ 1 entry besides paragraph).
  3. Temporarily clear Allowed Blocks, create a contentlet, insert an audio block, Save.
    Confirm via GET /api/v1/content/{identifier} that the stored JSON contains
    {"type":"dotAudio", ...}.
  4. Restore Allowed Blocks to include Audio.
  5. Reopen the contentlet.

Actual: the audio block is gone from the editor. Typing anything and saving removes it from
storage permanently.
Expected: the audio block loads and renders.

Variant: repeat with an aiContent node and AI Content ticked in Allowed Blocks — same
result.

B — Restricting any block kills the YouTube tab
  1. FEATURE_FLAG_NEW_BLOCK_EDITOR=true (default).
  2. Field settings → Allowed Blocks → select e.g. Bulleted List only.
  3. In a contentlet, open the toolbar's Add asset by URL popover.

Actual: the YouTube option is disabled/absent. Expected: present — youtube is not
something an administrator can restrict, so it must never be gated.

C — Ticking "AI Content" hides AI Content
  1. FEATURE_FLAG_NEW_BLOCK_EDITOR=true, AI plugin installed.
  2. Field settings → Allowed Blocks → select AI Content and AI Image (plus anything else).
  3. In a contentlet, type / to open the slash menu.

Actual: AI Content and AI Image are absent. Expected: present, because they were
explicitly allowed.

D — Inline code and unknown marks are destroyed on the front end
  1. In the new Block Editor, select text, apply inline code (toolbar), then bold the same
    run so it stores marks: [{type:'code'},{type:'bold'}]. Save and publish.
  2. Render the field through the Angular SDK (classic), the React SDK, the Vue SDK, and a VTL page
    using #renderMarks.

Actual: no <code> anywhere; in React, Vue and classic Angular the text is also not bold.
Expected: the code mark renders as <code>, and in every case the marks after it still
render.

Variant: inject a dotUnsupportedMark (or any undeclared mark) stacked before bold and render
through the three SDKs — the bold disappears. Render the same payload through the semantic
Angular renderer — the bold survives. That contrast is the specification for the fix.

D2 — One link, two anchors, without any emoji involved
  1. Write this directly to a Story Block field via /api/v1/content (no editor involved — this is
    the point):
    [ {"type":"text","marks":[{"type":"link","attrs":{"href":"https://dotcms.com"}}],"text":"one "},
      {"type":"text","marks":[{"type":"link","attrs":{"href":"https://dotcms.com"}}],"text":"link"} ]
    
  2. Render through VTL, React, Vue and both Angular renderers.

Actual: <a href="…">one </a><a href="…">link</a> — two anchors, two tab stops.
Expected: a single <a>. Adjacent text nodes carrying an identical link mark are one link.

E — One empty paragraph blanks a whole article
  1. Store a Story Block value containing an empty-array node anywhere in the tree:
    {"type":"doc","content":[
      {"type":"paragraph","content":[{"type":"text","text":"This paragraph must render."}]},
      {"type":"paragraph","content":[]}
    ]}
    
  2. Render the field through the React, Vue or either Angular renderer.

Actual: the field renders nothing at all; the console shows
Error in nested block at index 1: Error: Blocks content is empty. At least one block is required.
Expected: the first paragraph renders and the empty node is skipped.


Acceptance Criteria

A — The legacy load path stops destroying content
  • removeInvalidNodes is no longer called on the load path in
    dot-block-editor.component.ts:797-804. setEditorJSONContent returns preservedContent
    unconditionally.
  • A node type that is registered in the schema loads as itself, whatever allowedBlocks
    says — implementing the principle already stated in #37175: "Allowed Blocks restricts which
    blocks an author can insert; it must never prevent existing content from loading."
  • A node type that is not registered still arrives as a dotUnsupportedBlock placeholder
    via the existing preserveUnknownBlockNodes pass, and still round-trips its original JSON
    byte-for-byte on save.
  • A field whose Allowed Blocks includes Audio loads stored dotAudio nodes, and a Save
    round-trip preserves them.
  • A field whose Allowed Blocks includes AI Content loads stored aiContent nodes (as the
    real node when registered, as a placeholder otherwise) — never deleted.
  • Accepted behavioural consequence, called out for the reviewer: legacy content containing
    a node the field now excludes (e.g. a table on a field restricted to lists) renders again
    instead of being deleted. This matches the new editor exactly and is the intended outcome —
    restriction governs insertion, not loading.
  • Insertion restriction is unchanged: the + action menu, slash menu and bubble menu on a
    restricted field still offer only the allowed blocks.
  • parser.utils.spec.ts is updated — its current suite pins the deleting behaviour and will
    fail. Assertions that encode "nodes outside allowedBlocks are dropped from loaded content"
    are replaced by assertions that they are preserved.
  • removeInvalidNodes / purifyNodeTree / getBlockMap are either deleted outright (if
    nothing outside dot-block-editor.component.ts consumes them — they are re-exported through
    libs/block-editor/src/lib/shared/index.ts) or kept with a doc comment stating they are no
    longer part of the load path. State which, and why, in the PR description.
B — youtube is ungated
  • toolbar.component.ts:225 no longer consults isAllowed('youtube').
  • asset-by-url-popover.component.ts:83 no longer consults isAllowed('youtube').
  • A field restricted to any subset still offers the YouTube tab in "Add asset by URL".
  • image and video in the same popover remain gated — they are producible.
C — AI keys are corrected
  • slash-menu-catalog.ts:468,476 consults aiContentPrompt / aiImagePrompt.
  • Ticking AI Content / AI Image in Allowed Blocks makes them appear in the slash menu
    of a restricted field.
  • Not ticking them, on an otherwise restricted field, keeps them hidden.
  • An unrestricted field shows both, as today.
  • The AI plugin's own availability check (store.aiInstalled()) is unchanged and still wins.
D — Delivery-layer mark parity
  • Angular SDK (classic) text.component.ts renders the code mark as <code>.
  • React SDK Texts.tsx renders the code mark as <code>.
  • Vue SDK TextBlock.vue renders the code mark as <code>.
  • Angular SDK (semantic) dotcms-block-editor-renderer-native.component.html renders the
    code mark as <code>.
  • VTL #macro(renderMarks) emits <code> in the opening loop
    (VM_global_library.vm:13-44) and </code> in the closing loop (lines 46-70),
    each in the position that keeps the two loops exact mirrors of one another. Verify the
    rendered HTML is balanced for marks: [code, bold] and marks: [bold, code] — an
    open-without-close here corrupts every published page carrying the mark, it does not merely
    drop a tag.
  • No change to $start / $end handling: they are set in the first #if($content.marks)
    block and consumed by the second (see D3).
  • highlight is NOT painted on the front end — decision carried over from #37149: these
    marks are UI residue from the legacy link popover's setHighlight() selection indicator, not
    authorial intent, and there is no authoring button for them. It must, however, stop
    swallowing subsequent marks (next bullet).
  • dotUnsupportedMark is NOT painted either, for the same reason.
  • React, Vue and classic-Angular keep recursing through the remaining marks when a mark has
    no renderer
    — this is the core fix, and the semantic Angular renderer
    (...native.component.html:355-362) is the reference implementation to copy, comment
    included. marks: [code, bold], marks: [highlight, bold] and
    marks: [dotUnsupportedMark, bold] all render their text bold. Today all three render
    unformatted plain text.
  • The React renderer's mutation of mark.attrs (Texts.tsx:124-127 writes className onto
    and deletes class from the caller's object) is not made worse by this change; fixing it
    is welcome but optional.
D2 — Adjacent identical link marks coalesce into one anchor
  • VTL, React, Vue and both Angular renderers emit one <a> for a run of adjacent text
    nodes whose link marks are attribute-identical (href, target, title, rel,
    aria-label, class).
  • The VTL fix goes in #macro(renderContentBlock) (static/storyblock/render.vtl, the
    $element.type == "text" branch at line 97), not in #renderMarks — the macro only
    ever sees one text node, so the look-ahead belongs to the caller. #renderMarks keeps its
    current single-node contract.
  • Text nodes whose link marks differ in any attribute still produce separate anchors.
  • Marks stacked inside the run still render per node —
    text(link+bold) + text(link) is <a><strong>…</strong>…</a>, one anchor.
  • A regression test uses the #37340 payload shape directly (stored JSON, no editor), so the
    coverage does not depend on the editor-side heal shipped in #37442.
  • Closes the "Gap B" left open by #37340 — reference that issue in the test comment.
E — The delivery validator degrades instead of rejecting
  • isValidBlocks no longer treats content: [] as an error at any depth
    (libs/sdk/uve/src/lib/editor/internal.ts:65-70).
  • A node with an empty content array is skipped; every sibling and ancestor still
    renders. The document as a whole is never discarded because of it.
  • The genuinely fatal cases stay fatal and keep their current messages: blocks undefined,
    not an object, type !== 'doc', content missing or not an array, a block missing type.
  • A regression test uses the exact FD #38931
    payload — {"type":"paragraph","content":[]} nested among real paragraphs — and asserts the
    real content renders.
  • Verified across all four consumers: React, Vue, Angular classic, Angular semantic.
  • The customer-side stripEmptyBlocks workaround shipped to CITGO becomes unnecessary. Note
    in the PR that support can retire it.
Invariants — the reason this issue exists

These are the deliverable. Without them the class returns; it has returned six times in sixteen
months
, and once (FD #38349) it was closed as
user error while the real issue sat open.

  • I1 — no gate on a non-producible key. A test that imports getEditorBlockOptions() and
    fails if any capability key consulted by editor-extensions.ts, the toolbar, the slash-menu
    catalog, or the asset-by-url popover is absent from that list. paragraph is exempt
    (special-cased as always permitted). This test must fail on main today for youtube,
    aiContent and aiImage, and pass after B and C.
  • I2 — no producible key without a destination. The inverse: every option
    getEditorBlockOptions() can emit must resolve to something real in both editors —
    a registered extension, a gate, or a documented always-on capability. Fails today for
    aiContentPrompt / aiImagePrompt.
  • I3 — schema-to-delivery mark parity. A test that derives the mark names from the new
    editor's schema and fails if any is unhandled by all five renderers: the VTL macro, the
    React SDK, the Vue SDK, the classic Angular renderer and the semantic Angular renderer.
    A mark deliberately not painted (highlight, dotUnsupportedMark) satisfies it by being on
    an explicit, commented allowlist — never by being forgotten. This test must fail on main
    today for code, in five places.
  • I3-VTL — the Velocity half of I3 is a text-level assertion. #renderMarks has no test
    harness and must not get a Velocity runtime or a Java integration test (out of scope). The
    spec reads VM_global_library.vm as a string and fails if any allowlisted mark is absent
    from either the opening or the closing loop, or if the two loops do not list the same
    marks. Cheap, runtime-free, and it catches the unbalanced-HTML mistake that D3 describes.
  • I4 — no renderer discards a document it can partly render. A test that feeds each of the
    five renderers a document containing (a) an unknown mark stacked before a known one,
    (b) an unknown node type, and (c) a node with content: [], and asserts that every
    recognisable sibling still reaches the output. This is the invariant behind defects D and
    E, and it is what makes the class stop mattering even when a new mark or node appears that
    nobody remembered to register. Fails today in four of the five renderers.
  • Each invariant test carries a comment naming the issues and tickets it would have caught
    (I1: #37175, #36351, #37340, FD #38349 ·
    I2: this issue, defect A · I3: #36572, #37145 ·
    I4: FD #38931, #37313) so the next person to
    hit a red build understands why the rule exists.
  • specs/36351-block-editor-link-setting/contracts/allowed-blocks-capability-keys.md is
    updated: the youtube and aiContent/aiImage rows move from "known violation, out of
    scope"
    to resolved, and the document points at the tests that now enforce it.
  • core-web/libs/new-block-editor/CLAUDE.md records the enforced contract and the
    never-delete-on-load invariant for the legacy editor.
  • A short note in the SDK docs states the delivery contract in one line: a renderer never
    discards a document it can partly render.
Regression coverage
  • The existing suites stay green: nx test new-block-editor, nx test block-editor,
    nx test sdk-angular, nx test sdk-react, nx test sdk-vue, nx test sdk-uve.
    Note: nx test block-editor has 10 pre-existing failures
    (this.editor.setEditable is not a function, an incomplete spec mock) — identical on clean
    main. Verify by stashing; do not attempt to fix them here.
  • No change to stored JSON, REST contracts, the GraphQL Story Block fetcher, or the database.
    Frontend + one VTL macro only — rollback-safe and a clean backport candidate.
  • Defect E changes a published SDK contract: documents that previously errored now render.
    No consumer can be relying on the blank-body behaviour, but call it out in the changelog for
    @dotcms/uve, @dotcms/react, @dotcms/angular and @dotcms/vue.

Implementation guide

Suggested order — each step is independently testable and independently revertable.

Step Defect Files
1 I1 + I3 (red first) new spec files under libs/new-block-editor/src/lib/editor/extensions/ and a shared parity spec
2 B toolbar.component.ts:225, asset-by-url-popover.component.ts:83
3 C slash-menu-catalog.ts:468,476
4 I2 (red first) spec against getEditorBlockOptions() + both editors
5 A dot-block-editor.component.ts:797-804, parser.utils.ts, parser.utils.spec.ts
6 I4 (red first) one shared spec exercising all five renderers
7 E libs/sdk/uve/src/lib/editor/internal.ts + the four consumers
8 D text.component.ts, Texts.tsx, TextBlock.vue, VM_global_library.vm — copy the semantic renderer's @default branch
9 D2 the same five renderers: coalesce before emitting
10 docs capability-keys contract, new-block-editor/CLAUDE.md, SDK changelog

Write the failing test first for every step — I1, I3 and I4 must be demonstrably red on main
before B, C, D and E are touched. The QA rounds on #37175 are the model: PR #37205 shipped with
AC 5 unmet and QA caught it in the running app, which is what forced #37313. #36985 is the other
model, in the opposite direction: it was reopened twice because the first two fixes addressed
the symptom the ticket described rather than the mechanism underneath.

Verify in the browser, not only in Jest. Every defect in this family was invisible to unit
tests and obvious in the app within thirty seconds. For A specifically, confirm the byte-for-byte
save round-trip on real content — that is the evidence that matters.


Out of scope

These are real and evidenced, but they belong to other subsystems or other owners. Listed so they
are not lost a second time.

  • Indexing is explicitly out of scope for this issue. FD #38957 carries two unrelated problems: the
    Block Editor one (the highlight mark, #37145, in the family above) and an ElasticSearch
    reindex incident. The reindex side is backend, belongs to the indexing owners, and is not
    tracked anywhere
    — support reproduced it and drafted the RCA on the ticket. Flagged here only
    so it is not lost; nothing about it belongs in this work.

  • Block Editor picker pagination — a different defect class, and already resolved.
    FD #38277 (RBC GAM, content-type picker capped at
    40 → #36487 → PR #36488) and FD #38290 (folder
    tree stops at "H", Resources unreachable → #36733 → PR #36848) are load-more pagination in two
    unrelated components, with no schema involvement. Both are fixed; neither belongs here.

    Recorded because the second one is a useful counter-example to this issue's thesis. #36733 is
    labelled Type : Refactoring / dotCMS : Technical Debt, and its acceptance criteria say
    load-more must keep working "as before" — it reads as a pure refactor that cannot fix a bug.
    It fixed one anyway. The Block Editor's image/video/audio pickers open
    DotBrowserSelectorComponent (editor-modal.service.ts:204) → DotSideBarComponent
    browser.store.ts, and before PR #36848 that store called
    getFoldersTreeNode(hostname/path) with no pagination arguments at all — one batch, no way
    to ask for more. The PR replaced it with { page, per_page: DOT_FOLDER_TREE_PAGE_SIZE } plus a
    loadMore rxMethod and a "Load more" node (dot-sidebar.component.html:13-18).
    git log -S "loadMore" on that store returns exactly one commit: 8c725747c0b, the refactor
    itself. Unifying the three p-tree implementations gave the Browser Selector a capability it had
    never had.

    The lesson is the same one this issue is about, from the other direction: behaviour moved
    between components with nothing asserting what it was. Nobody could tell from the labels, the
    ACs or the issue title whether the customer's bug was fixed — it took reading the diff. FD #38290
    was linked to the wrong issue twice (#36487, then #36733) before anyone checked. Remaining
    caveat, not tracked anywhere: the page size is 40
    (DOT_FOLDER_TREE_PAGE_SIZE, SITE_PAGE_LIMIT), so a site with many folders now reaches
    Resources only after repeated "Load more" clicks.

  • TypeError: o.can(...).mergeCells is not a function when table is not in allowedBlocks
    (table-handle-popover.component.ts:283-295 instantiates unconditionally and re-binds on every
    transaction). Pre-existing, console noise only, flagged by QA on #37175 twice. Deserves its own
    issue.

  • Security hardening of the customBlocks remote-extension loader (arbitrary JS in the dotAdmin
    origin) — documented in #36668, a different domain (CSP + backend config + permissions).

  • Adding a Highlight toolbar button. #37149 argued the case against it and nothing here changes
    that reasoning.

  • Native text-colour support (FD #38800, #37235) —
    a feature request, not a defect, and not this class.

  • Any change to the bubble-menu positioning that opened FD #37852. The new editor's static toolbar
    already addresses it. Note that #36351 was opened for that symptom and closed for the
    hyperlink gate — the positioning complaint was never answered on its own terms.

dotCMS Version

main @ 667fc831ee (2026-09-17). Defects B, C, D and D2 affect every build where
FEATURE_FLAG_NEW_BLOCK_EDITOR is available (since #35257, 2026-05-08). D, D2 and E also affect
every consumer of the Angular/React/Vue SDK renderers and the VTL macro regardless of the flag,
and regardless of which editor wrote the content
— E in particular is reachable by any writer
that is not the editor at all. Defect A affects the legacy editor and predates TipTap 3 — the
filter has behaved this way since the TipTap 2 era (~#24007).

Severity

High - Major functionality broken

Defect A causes silent, permanent content loss on a configuration an administrator chose
deliberately. Defect E has already blanked 461 published articles for one customer
(FD #38931) and was resolved by asking them to
patch their own front end. Defects D and D2 silently destroy authored formatting and accessible
link semantics on published pages. Not Critical: each is scoped to fields with Allowed Blocks
configured, to specific marks, or to non-editor-authored content, and A has a no-deploy workaround
(clear Allowed Blocks) — the same workaround support has now given four customers, and diagnosed
as user error for a fifth.

Links

Freshdesk — the family

  • #37852 — Muskegon County (merged: 38035, 38092)
  • #38349 — Methodist Health · closed as user error, no issue filed
  • #38997 — Michigan State University
  • #38993 — Worldline / Logeecom
  • #38957 — CarFinance247 (highlight; also carries the untracked reindex incident)
  • #35973 — University of Akron (2026-03, the #35032 round)
  • #39197 — Lennox Commercial (© ® link split)
  • #38460 — custom remote blocks
  • #38931 — CITGO, 461 blank articles (Defect E)

Freshdesk — related, other classes

  • #38740 — Worldline, contentlet selection (#36985, reopened twice; attribute-serialization divergence)
  • #38277 — RBC GAM, picker capped at 40 (#36487)
  • #38290 — folder tree stops at "H" (mis-linked twice)
  • #38800 — text colour, feature request (#37235)

Issues — #36351 · #37145 (still open although PR #37149 merged 2026-08-21) · #37175 ·
#36668 · #36572 · #37340 · #35032 · #36985

PRs — #37149 · #37205 · #37313 · #37539 · #37442 · #37104 · #37319 · introduced by #35257
(669631d735)

Historic recurrences of this class — #35032 (2026-03) · #34110 (2025-12) · #29772 (2025-05) ·
#27101 (2024-01) · #24370 (2023-05) · #23764 (2023-01) · #22164 (2022-08)

Contractspecs/36351-block-editor-link-setting/contracts/allowed-blocks-capability-keys.md

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the capability contract in specs/36351-block-editor-link-setting/contracts/allowed-blocks-capability-keys.md, then trace the listed editor entry points in suggestion.utils.ts, editor-extensions.ts, parser.utils.ts, dot-block-editor.component.ts, the slash menu, toolbar, and asset popover. Review the delivery templates VM_global_library.vm and static/storyblock/render.vtl alongside the SDK renderers. Done means the six live defects are addressed across the frontend paths and the contract is enforced rather than remaining documentation-only.

Written by the indexing model from the issue text.

Assessment

Tech stack
angular, react, typescript
Domain
frontend
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.