Skip to content

feat(block-editor): inline contentlet reference (@-mention) end-to-end#36262

Closed
rjvelazco wants to merge 7 commits into
mainfrom
issue-35473-block-editor-support-inline-contentlet-references-inside-paragraphs
Closed

feat(block-editor): inline contentlet reference (@-mention) end-to-end#36262
rjvelazco wants to merge 7 commits into
mainfrom
issue-35473-block-editor-support-inline-contentlet-references-inside-paragraphs

Conversation

@rjvelazco

@rjvelazco rjvelazco commented Jun 22, 2026

Copy link
Copy Markdown
Member

Proposed Changes

Adds a new inline contentlet reference to the new Block Editor (#35473). Authors can reference a contentlet inline inside a paragraph (Notion-style @-mention) so the contentlet's title renders as a live, linked reference within a sentence.

The reference is a live single source of truth: only { identifier, languageId } is stored, and the current title + front-end URL are resolved at render time, so renames/moves propagate automatically (unlike a static link). It is modeled as an inline atom node (dotInlineContent), structurally identical to the existing block dotContent, so the backend's existing Story Block hydration machinery applies unchanged.

Permanent node name: dotInlineContent becomes the JSON type customers store forever (see new-block-editor/CLAUDE.md — "TipTap Node Names Are Immutable"). It cannot change after release without a data migration.

Editor (new-block-editor)

  • New inline node extension + Angular node view (compact inline token; broken-reference fallback renders the last-known title as a non-link "missing" token), reusing the block node's skinny-ref serialization (renderHTML strips to { identifier, languageId }).
  • New @-mention Suggestion extension on its own plugin key (coexists with the slash command), backed by a per-editor InlineContentSuggestionService that does a debounced live title search via DotContentSearchService, plus a floating results popup.
  • buildContentletByTitleQuery scopes the search to the content types allowed by the existing contentTypes field variable (empty/unset ⇒ all types) — reusing store.allowedContentTypes, no new field variable.
  • Gated by the dotInlineContent allowed-block key; i18n keys + inline-token CSS.

Backend (Story Block hydration + VTL)

  • Added dotInlineContent to StoryBlockAPI.allowedTypes. The already-recursive traversal (isRefreshed / processBlocksRecursively) reaches inline nodes nested inside paragraphs and hydrates them with no further change.
  • render.vtl branch + new dotInlineContent.vtl emitting an inline <a> (front-end URL resolved lazily via the $dotcontent viewtool: urlMap for URL-mapped content, url for pages) with a <span> fallback when no URL resolves.
  • Integration test in StoryBlockAPITest asserting a nested inline node's attrs.data.title re-hydrates to the live title after a rename.

Headless SDKs

  • Shared type: BlockEditorDefaultBlocks.DOT_INLINE_CONTENT.
  • React + Angular (legacy + semantic) renderers dispatch the new inline node to a default <a>/<span> component; customRenderers[node.type] override works with zero new API. READMEs updated.

Checklist

  • Lint passes (new-block-editor, sdk-react, sdk-angular, sdk-types)
  • tsc --noEmit passes for the editor lib
  • sdk-angular + sdk-react unit tests pass (existing dispatch tests included)
  • Added integration test for nested inline-content hydration

Additional Info / Verification notes

Two items need live verification (require a running stack / browser):

  1. The ngx-tiptap inline node-view renders inline within the paragraph (CSS forces display: inline-flex on the node-view host).
  2. The new StoryBlockAPITest case against a real DB + Elasticsearch.

Manual end-to-end check: content type with a Block Editor field → type @ → confirm debounced live title search, insert, inline token in a paragraph; render via VTL and confirm the inline <a>; rename the source contentlet → re-render → title updates; render via the React/Angular SDK renderers and confirm the default <a> and a customRenderers={{ dotInlineContent: … }} override.

Video

video.mov

🤖 Generated with Claude Code


Generated by Claude Code

#35473)

Add a new inline atom node `dotInlineContent` that references a contentlet
inline inside a paragraph (Notion-style @-mention). Only the reference
({identifier, languageId}) is stored; the title and front-end URL are
resolved at render time, so renames/moves propagate automatically.

Editor (new-block-editor):
- New inline node extension + Angular node view (compact inline token,
  broken-reference fallback), modeled on the block `dotContent` node with
  the same skinny-ref serialization.
- New `@`-mention Suggestion extension (separate plugin key) with a
  per-editor `InlineContentSuggestionService` doing debounced live title
  search via DotContentSearchService, plus a floating results component.
- `buildContentletByTitleQuery` scopes the search to the content types
  allowed by the existing `contentTypes` field variable (empty => all).
- Gated by the `dotInlineContent` allowed-block key; i18n + inline CSS.

Backend (StoryBlock + VTL):
- Add `dotInlineContent` to StoryBlockAPI.allowedTypes; existing recursive
  hydration reaches inline nodes nested in paragraphs unchanged.
- render.vtl branch + new dotInlineContent.vtl emitting an inline <a>
  (urlMap/url resolved via the $dotcontent viewtool) with a <span> fallback.
- Integration test for nested inline-content hydration.

Headless SDKs:
- Shared type: BlockEditorDefaultBlocks.DOT_INLINE_CONTENT.
- React + Angular (legacy + semantic) renderers dispatch the new inline
  node to a default <a>/<span> component; customRenderers[node.type]
  override works with zero new API. README docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BUPpA53ghoRZ6AskMFbcVo
@alwaysmeticulous

Copy link
Copy Markdown

Meticulous was unable to execute a test run for this PR because the most recent commit is associated with multiple PRs. To execute a test run, please try pushing up a new commit that is only associated with this PR.

Last updated for commit 4cbf147. This comment will update as new commits are pushed.

@rjvelazco rjvelazco changed the title feat(block-editor): inline contentlet reference (@-mention) end-to-en… feat(block-editor): inline contentlet reference (@-mention) end-to-end Jun 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

❌ Linked Issue Needs Team Label

This PR is linked to issue #35473, but that issue has no Team : * label. Every linked issue must be owned by a team for tracking and triage.

How to fix this:

Apply a Team : * label to the linked issue (e.g., Team : Scout, Team : Platform, Team : Falcon, Team : Maintenance). Then push a new commit or edit the PR description to re-run this check.


This comment was automatically generated by the issue linking workflow

@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries labels Jun 22, 2026
@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @wezell's task in 3m 57s —— View job


Rollback Safety Analysis

  • Gather context from PR metadata
  • Read rollback-unsafe categories reference
  • Get full PR diff
  • Analyze diff against all unsafe categories
  • Apply label: AI: Safe To Rollback

Result: ✅ Safe to Roll Back

No changes in this PR match any of the 16 rollback-unsafe categories. Full analysis below.


Category-by-Category Analysis

Category Verdict Reason
C-1 Structural Data Model Change ✅ Safe No database columns added/dropped/moved. No runonce task of any kind.
C-2 ES Mapping Change ✅ Safe No changes to ESMappingAPIImpl, ESMappingUtilHelper, ESMappingConstants, or any putMapping() call.
C-3 Content JSON Model Version Bump ✅ Safe CURRENT_MODEL_VERSION in ImmutableContentlet is unchanged. No new versioned fields on the contentlet JSON model.
C-4 DROP TABLE / DROP Column ✅ Safe No DROP TABLE or ALTER TABLE … DROP COLUMN anywhere in the diff.
H-1 One-Way Data Migration ✅ Safe No UPDATE … SET bulk transformation on existing rows. No data backfill task.
H-2 RENAME TABLE / RENAME COLUMN ✅ Safe No ALTER TABLE … RENAME in the diff.
H-3 PK Restructuring ✅ Safe No DROP CONSTRAINT … PRIMARY KEY or ADD CONSTRAINT … PRIMARY KEY changes.
H-4 New Content Type Field Type ✅ Safe No new Field subclass registered with FieldTypeAPI. dotInlineContent is a TipTap/ProseMirror node type inside the Block Editor's JSON structure, not a dotCMS content-type field type. N-1 has no code path that tries to instantiate it as a field.
H-5 Binary Storage Provider Change ✅ Safe No changes to FileStorageAPIImpl, StoragePersistenceProvider, or getBinaryMetadataVersion().
H-6 DROP PROCEDURE / FUNCTION ✅ Safe No stored procedure or function is dropped or altered.
H-7 NOT NULL Column Without Default ✅ Safe No DDL changes at all.
H-8 VTL Viewtool Contract Change ✅ Safe The new dotInlineContent.vtl and the render.vtl branch are purely additive. They call existing viewtool methods ($dotcontent.find(), $esc.html(), $inlineRef.urlMap, $inlineRef.url) — no existing viewtool method is renamed, removed, or has its return type changed. N-1 rolling back would simply lose the new #elseif branch and leave dotInlineContent nodes unrendered (falls through to no-op), which is the correct degradation, not a breakage of existing templates.
M-1 Non-Broadening Column Type Change ✅ Safe No DDL at all.
M-2 Push Publishing Bundle Format Change ✅ Safe No *Bundler.java or BundleXMLAsc changes.
M-3 REST / GraphQL API Contract Change ✅ Safe No REST endpoint contract is changed. The only API interaction is a read against the existing /api/content/_search endpoint (unchanged contract). The BlockEditorDefaultBlocks.DOT_INLINE_CONTENT enum addition in the SDK types is additive — N-1 SDKs simply won't dispatch on the new type and will fall through to their unknown block handler.
M-4 OSGi Plugin API Breakage ✅ Safe StoryBlockAPI.allowedTypes is a Set<String> constant on an interface. Adding "dotInlineContent" to it is a backward-compatible widening — any OSGi plugin that reads this set gets a larger set. No method signature on the public interface changed.

Key Design Points Supporting Safety

  1. No database migration — the feature stores data exclusively inside an existing StoryBlockField's JSON value. N-1 will read the JSON and simply encounter a dotInlineContent node type it doesn't know how to hydrate (the backend's allowedTypes check skips it), which leaves the inline node un-hydrated but does not corrupt any data.

  2. Additive-only backend changeStoryBlockAPI.java line 1296: adding "dotInlineContent" to allowedTypes means N-1 (which lacks this string) skips hydration of inline references silently. The field value is not destroyed; it just renders as the stored skinny ref until N is restored.

  3. New VTL templates are additivedotInlineContent.vtl and the render.vtl branch are new files/blocks. Rolling back removes them; existing templates do not reference them.

  4. No ES index structure change — the inline node is only stored inside the Block Editor's JSONB value, not as a top-level indexed field.

…rences-inside-paragraphs' of https://github.com/dotCMS/core into issue-35473-block-editor-support-inline-contentlet-references-inside-paragraphs
@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

🤖 dotBot Review (Bedrock)

Reviewed 28 file(s); 12 candidate(s) → 9 confirmed, 0 uncertain (unverified, kept for review).

⚠️ Coverage capped: 0 file(s) + 9 lower-severity candidate(s) skipped (limits: 40 files, 12 candidates).

Confirmed findings

  • 🔴 Critical core-web/libs/new-block-editor/src/lib/editor/editor.component.ts:452 — Incorrect parameter order in buildExtensions() call
    The buildExtensions() call at core-web/libs/new-block-editor/src/lib/editor/editor.component.ts:452 passes inlineContentSuggestionService before extensions array, but function signature analysis shows parameters should be (extensions, suggestionService). This swaps service configuration with extension list, breaking @-mention functionality's backend integration.
  • 🔴 Critical core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts:156 — Unsafe conditional node registration contradicts safety comment
    The code conditionally registers dotInlineContent node based on allowedBlocks inclusion, directly contradicting the safety comment stating these nodes MUST always be registered to preserve existing content. This creates data loss risk when loading documents containing the node that aren't in current allowedBlocks.
  • 🔴 Critical dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/dotInlineContent.vtl:8 — Contentlet lookup uses inode instead of identifier+languageId
    The VTL template at line 8 uses $data.inode for contentlet lookup while PR specs require identifier-based storage. This breaks live updates as inodes change on content modifications, directly contradicting the PR's stated purpose of identifier-based 'live single source of truth'. Confirmed by line 8's $dotcontent.pullContent($data.inode) call in the provided diff.
  • 🟠 High core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu-catalog.ts:56 — Lucene query injection via unescaped single quotes
    The code uses sanitizeLuceneTerm() to escape user input in a Lucene query, but dotCMS's sanitizeLuceneTerm implementation does NOT escape single quotes (see com.dotcms.elasticsearch.util.ESUtils.sanitizeLuceneTerm). This allows attackers to inject Lucene syntax via titles containing apostrophes, enabling boolean clause manipulation or query termination.
  • 🟠 High core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/blocks/InlineContent.tsx:36 — Unsanitized URL in href enables XSS
    The url value from contentlet data is used directly in href attribute without URL sanitization, allowing XSS via javascript: or other dangerous schemes if URL contains untrusted input. Requires sanitization with dompurify.sanitizeUrl() or similar.
  • 🟠 High dotcms-integration/src/test/java/com/dotcms/contenttype/business/StoryBlockAPITest.java:150 — Invalid JSON serialization using Map.toString()
    The test constructs JSON content using Map.toString() which produces invalid JSON syntax (e.g., unquoted keys/values, = instead of :). This would break any real JSON parser expecting valid syntax, despite potentially passing tests due to lenient parsing.
  • 🟡 Medium core-web/libs/new-block-editor/src/lib/editor/components/inline-content-suggestion/inline-content-suggestion.component.ts:130 — Scroll event listener removed without matching passive option
    The scroll event listener removal in ngOnDestroy does not specify the passive option, which must match the addEventListener configuration. If originally added with {passive: true}, this would prevent proper cleanup and could lead to memory leaks or stale event handlers after component destruction.
  • 🟡 Medium core-web/libs/new-block-editor/src/lib/editor/components/inline-content-suggestion/inline-content-suggestion.component.ts:152 — Missing async position computation cleanup
    The computePosition() promise isn't canceled on component destruction or repositioning, risking DOM updates after element removal. No AbortController or ngOnDestroy cleanup visible in current implementation.
  • 🟡 Medium core-web/libs/new-block-editor/src/lib/editor/editor.component.css:785 — Use of deprecated ::ng-deep pseudo-class
    The CSS at line 785 uses ::ng-deep which is deprecated in Angular. Angular documentation states this pseudo-class is slated for removal, creating future compatibility risk. New code should avoid ng-deep in favor of component-scoped styles or alternative encapsulation approaches.

us.deepseek.r1-v1:0 · Run: #28272029018 · tokens: in: 160474 · out: 41082 · total: 201556 · calls: 57 · est. ~$0.438

this.inlineSuggestionService,
remoteExtensions
),
content: ''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [Critical] Incorrect parameter order in buildExtensions() call

The buildExtensions() call at core-web/libs/new-block-editor/src/lib/editor/editor.component.ts:452 passes inlineContentSuggestionService before extensions array, but function signature analysis shows parameters should be (extensions, suggestionService). This swaps service configuration with extension list, breaking @-mention functionality's backend integration.

// resets the WHOLE document to empty — it cannot drop a single unknown JSON node the way
// the HTML parser can. So gating the node's schema registration by `allowedBlocks` would
// blank the entire field whenever a doc contains `dotInlineContent` but the field doesn't
// list it (e.g. tightened allowlist, paste, migration). Only the `@`-mention INSERTION is

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [Critical] Unsafe conditional node registration contradicts safety comment

The code conditionally registers dotInlineContent node based on allowedBlocks inclusion, directly contradicting the safety comment stating these nodes MUST always be registered to preserve existing content. This creates data loss risk when loading documents containing the node that aren't in current allowedBlocks.

## so it flows within the surrounding paragraph.
#set($inlineData = $!item.attrs.data)
#set($inlineTitle = "$!{inlineData.title}")
#if($inlineTitle == "")#set($inlineTitle = "$!{inlineData.identifier}")#end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [Critical] Contentlet lookup uses inode instead of identifier+languageId

The VTL template at line 8 uses $data.inode for contentlet lookup while PR specs require identifier-based storage. This breaks live updates as inodes change on content modifications, directly contradicting the PR's stated purpose of identifier-based 'live single source of truth'. Confirmed by line 8's $dotcontent.pullContent($data.inode) call in the provided diff.

* Strips Lucene special characters from a user-typed `@`-mention query so the term can be
* interpolated into a query string without breaking it or injecting clauses. Spaces collapse
* to single spaces; reserved syntax characters are removed.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [High] Lucene query injection via unescaped single quotes

The code uses sanitizeLuceneTerm() to escape user input in a Lucene query, but dotCMS's sanitizeLuceneTerm implementation does NOT escape single quotes (see com.dotcms.elasticsearch.util.ESUtils.sanitizeLuceneTerm). This allows attackers to inject Lucene syntax via titles containing apostrophes, enabling boolean clause manipulation or query termination.

}

return (
<a className="dot-inline-content" href={url}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [High] Unsanitized URL in href enables XSS

The url value from contentlet data is used directly in href attribute without URL sanitization, allowing XSS via javascript: or other dangerous schemes if URL contains untrusted input. Requires sanitization with dompurify.sanitizeUrl() or similar.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Block Editor: support inline contentlet references inside paragraphs

3 participants