Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions dotCMS/src/main/java/com/dotcms/rest/MapToContentletPopulator.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.dotcms.contenttype.model.type.ContentType;
import com.dotcms.contenttype.transform.field.LegacyFieldTransformer;
import com.dotcms.rest.api.v1.temp.DotTempFile;
import com.dotcms.tiptap.TiptapMarkdown;
import com.dotcms.util.CollectionsUtils;
import com.dotcms.util.DotPreconditions;
import com.dotcms.util.RelationshipUtil;
Expand Down Expand Up @@ -255,6 +256,10 @@ private void fillFields(final Contentlet contentlet,
&& null != value && value instanceof Map) {

this.processPlainValueForBinaryField(map, field, value, contentlet);
} else if (FieldType.STORY_BLOCK_FIELD.toString().equals(field.getFieldType())
&& value instanceof String) {

this.processStoryBlockField(contentlet, field, (String) value);
} else {
APILocator.getContentletAPI()
.setContentletProperty(contentlet, field, value);
Expand All @@ -264,6 +269,59 @@ private void fillFields(final Contentlet contentlet,

} // fillFields.

/**
* Story Block fields store a Tiptap/ProseMirror JSON document. Non-interactive clients
* (AI agents, headless imports) may instead send Markdown. We convert it to ProseMirror
* JSON here, on the shared save path, so the field reads back as structured content with
* no human editor round-trip. Values that are already JSON (the dominant editor traffic)
* or HTML are stored unchanged — Markdown is the only thing converted, and a conversion
* failure never blocks the save.
*/
private void processStoryBlockField(final Contentlet contentlet, final Field field,
final String value) {

final String storyBlockJson = this.toStoryBlockJson(contentlet, field, value);
APILocator.getContentletAPI().setContentletProperty(contentlet, field, storyBlockJson);
}

private String toStoryBlockJson(final Contentlet contentlet, final Field field,
final String value) {

// Editor-authored JSON and (for now) HTML are stored as-is; Markdown is plain text and
// begins with neither '{' nor '<'. This mirrors the Block Editor's own client-side
// routing and avoids re-parsing an existing document as Markdown.
final String trimmed = value.stripLeading();
if (trimmed.isEmpty() || trimmed.charAt(0) == '{' || trimmed.charAt(0) == '<') {
return value;
}

// Markdown cannot represent rich blocks (embedded contentlets, video, layout grids). Per the
// documented contract (see the fire endpoints' Block Editor note), Markdown is for plain
// content only and must not be used to modify a field that already holds such blocks. If that
// is attempted, keep the existing document untouched and log a warning — neither destroying
// the rich content nor failing the save. (Markdown -> rich merge is planned as a follow-up.)
final String existing = contentlet.getStringProperty(field.getVelocityVarName());
Comment thread
hassandotcms marked this conversation as resolved.
if (TiptapMarkdown.isTiptapDoc(existing) && !TiptapMarkdown.isMarkdownRepresentable(existing)) {
Logger.warn(this, String.format(
"Story Block field [%s] holds rich content that Markdown cannot represent; "
+ "ignoring the Markdown value and keeping the existing document. Send a "
+ "full Tiptap/ProseMirror JSON document to modify this field.",
field.getVelocityVarName()));
Comment thread
fabrizzio-dotCMS marked this conversation as resolved.
return existing;
}

try {
return TiptapMarkdown.toTiptap(value).toString();
} catch (final Exception e) {
// Graceful degradation (consistent with the converter's #35728 contract): a parse
// failure must never block the save — store the original value and move on.
Logger.warn(this, String.format(
"Story Block field [%s]: Markdown conversion failed, storing value unchanged. %s",
field.getVelocityVarName(), e.getMessage()));
return value;
}
}

private static void processPlainValueForBinaryField(final Map<String, Object> map,
final Field field,
final Object value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,10 +228,15 @@ public class WorkflowResource {
"leave the default.";

private static final String BLOCK_EDITOR_FIELD_NOTE =
"\n\n**Block Editor (Story Block) fields:** send the value as an HTML or Markdown string — " +
"do not hand-author the underlying ProseMirror/JSON document. The value is stored as-is and " +
"converted to the Block Editor structure when the contentlet is opened in the editor. " +
"Example: `\"body\": \"<h2>Intro</h2><p>Hello <strong>world</strong>.</p>\"`.";
"\n\n**Block Editor (Story Block) fields:** send the value as a **Markdown** string — " +
"you do not need to hand-author the underlying ProseMirror/JSON document. dotCMS converts it " +
"to the Block Editor (ProseMirror JSON) structure automatically on save, so the field reads " +
"back as structured content with no editor round-trip required. A value that is already a " +
"valid Tiptap/ProseMirror JSON document is detected and stored unchanged. Markdown is intended " +
"for plain content: if the field already holds rich blocks that Markdown cannot represent " +
"(embedded contentlets, video or layout blocks), the Markdown value is ignored and the existing " +
"document is preserved — to modify such a field, send a full Tiptap/ProseMirror JSON document. " +
"Example: `\"body\": \"## Intro\\n\\nHello **world**.\"`.";

private static final String BULK_FIRE_CONTRACT_NOTES =
"⚠️ **Important contract notes:**\n\n" +
Expand Down
78 changes: 78 additions & 0 deletions dotCMS/src/main/java/com/dotcms/tiptap/TiptapMarkdown.java
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,84 @@ public static String toMarkdown(final com.dotmarketing.util.json.JSONObject tipt
return toMarkdown(tiptap.toString());
}

/**
* Cheap discriminator: is this string already a Tiptap/ProseMirror document
* ({@code {"type":"doc","content":[...]}})? Used on the save path to leave
* editor-authored JSON untouched rather than re-parsing it as Markdown. The
* first and last non-whitespace characters are peeked before any parse, so the
* common non-JSON (Markdown) case costs nothing.
*/
public static boolean isTiptapDoc(final String value) {
if (value == null) {
return false;
}
final String trimmed = value.strip();
if (trimmed.isEmpty() || trimmed.charAt(0) != '{'
|| trimmed.charAt(trimmed.length() - 1) != '}') {
return false;
Comment thread
fabrizzio-dotCMS marked this conversation as resolved.
}
try {
final JsonNode node = MAPPER.readTree(value);
return node != null
&& "doc".equals(node.path("type").asText())
&& node.path("content").isArray();
} catch (final java.io.IOException e) {
return false;
}
}

/**
* The ProseMirror node types {@link #toTiptap(String)} can produce from Markdown.
* A document built only from these can be expressed as Markdown without losing blocks.
*/
private static final Set<String> MARKDOWN_NODE_TYPES = Set.of(
"doc", "paragraph", "heading", "blockquote", "bulletList", "orderedList", "listItem",
"codeBlock", "horizontalRule", "hardBreak", "text",
"table", "tableRow", "tableHeader", "tableCell", "dotImage");

/**
* True when every block in the document is Markdown-representable (see
* {@link #MARKDOWN_NODE_TYPES}). Used to detect a Markdown overwrite that would
* silently destroy rich blocks Markdown cannot express — embedded contentlets
* ({@code dotContent}), video, layout grids, etc. Marks are intentionally not
* inspected: an unsupported mark loses styling, not content. A {@code null} or
* non-JSON value carries no rich blocks to protect, so returns {@code true}.
*/
public static boolean isMarkdownRepresentable(final String tiptapJson) {
if (tiptapJson == null || tiptapJson.isBlank()) {
return true;
}
try {
return isMarkdownRepresentable(MAPPER.readTree(tiptapJson));
} catch (final java.io.IOException e) {
// Not parseable JSON carries no rich blocks to protect; stay permissive, but leave a
// trace in case a caller passes a value it expected to be a valid document.
Logger.debug(TiptapMarkdown.class,
() -> "isMarkdownRepresentable: value is not valid JSON, treating as representable: "
+ e.getMessage());
return true;
}
Comment thread
hassandotcms marked this conversation as resolved.
}

private static boolean isMarkdownRepresentable(final JsonNode node) {
if (node == null) {
return true;
}
final String type = node.path("type").asText("");
if (!type.isEmpty() && !MARKDOWN_NODE_TYPES.contains(type)) {
return false;
}
final JsonNode content = node.path("content");
if (content.isArray()) {
for (final JsonNode child : content) {
if (!isMarkdownRepresentable(child)) {
return false;
}
}
}
return true;
}

// =====================================================================
// Markdown -> Tiptap JSON (commonmark Visitor)
// =====================================================================
Expand Down
12 changes: 6 additions & 6 deletions dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18757,7 +18757,7 @@ paths:

**When chaining workflow actions or reading state back immediately after firing, pass `indexPolicy=WAIT_FOR` on each call.** The default `DEFER` is asynchronous and can return stale index reads for several seconds, which can mimic server-side state bugs. For isolated one-off fires where nothing reads the result, leave the default.

**Block Editor (Story Block) fields:** send the value as an HTML or Markdown string — do not hand-author the underlying ProseMirror/JSON document. The value is stored as-is and converted to the Block Editor structure when the contentlet is opened in the editor. Example: `"body": "<h2>Intro</h2><p>Hello <strong>world</strong>.</p>"`.
**Block Editor (Story Block) fields:** send the value as a **Markdown** string — you do not need to hand-author the underlying ProseMirror/JSON document. dotCMS converts it to the Block Editor (ProseMirror JSON) structure automatically on save, so the field reads back as structured content with no editor round-trip required. A value that is already a valid Tiptap/ProseMirror JSON document is detected and stored unchanged. Markdown is intended for plain content: if the field already holds rich blocks that Markdown cannot represent (embedded contentlets, video or layout blocks), the Markdown value is ignored and the existing document is preserved — to modify such a field, send a full Tiptap/ProseMirror JSON document. Example: `"body": "## Intro\n\nHello **world**."`.
operationId: putFireDefaultSystemAction
parameters:
- description: Inode of the target content.
Expand Down Expand Up @@ -18895,7 +18895,7 @@ paths:

**When chaining workflow actions or reading state back immediately after firing, pass `indexPolicy=WAIT_FOR` on each call.** The default `DEFER` is asynchronous and can return stale index reads for several seconds, which can mimic server-side state bugs. For isolated one-off fires where nothing reads the result, leave the default.

**Block Editor (Story Block) fields:** send the value as an HTML or Markdown string — do not hand-author the underlying ProseMirror/JSON document. The value is stored as-is and converted to the Block Editor structure when the contentlet is opened in the editor. Example: `"body": "<h2>Intro</h2><p>Hello <strong>world</strong>.</p>"`.
**Block Editor (Story Block) fields:** send the value as a **Markdown** string — you do not need to hand-author the underlying ProseMirror/JSON document. dotCMS converts it to the Block Editor (ProseMirror JSON) structure automatically on save, so the field reads back as structured content with no editor round-trip required. A value that is already a valid Tiptap/ProseMirror JSON document is detected and stored unchanged. Markdown is intended for plain content: if the field already holds rich blocks that Markdown cannot represent (embedded contentlets, video or layout blocks), the Markdown value is ignored and the existing document is preserved — to modify such a field, send a full Tiptap/ProseMirror JSON document. Example: `"body": "## Intro\n\nHello **world**."`.
Comment thread
hassandotcms marked this conversation as resolved.
operationId: putFireDefaultActionMultipart
parameters:
- description: Inode of the target content.
Expand Down Expand Up @@ -18987,7 +18987,7 @@ paths:

**When chaining workflow actions or reading state back immediately after firing, pass `indexPolicy=WAIT_FOR` on each call.** The default `DEFER` is asynchronous and can return stale index reads for several seconds, which can mimic server-side state bugs. For isolated one-off fires where nothing reads the result, leave the default.

**Block Editor (Story Block) fields:** send the value as an HTML or Markdown string — do not hand-author the underlying ProseMirror/JSON document. The value is stored as-is and converted to the Block Editor structure when the contentlet is opened in the editor. Example: `"body": "<h2>Intro</h2><p>Hello <strong>world</strong>.</p>"`.
**Block Editor (Story Block) fields:** send the value as a **Markdown** string — you do not need to hand-author the underlying ProseMirror/JSON document. dotCMS converts it to the Block Editor (ProseMirror JSON) structure automatically on save, so the field reads back as structured content with no editor round-trip required. A value that is already a valid Tiptap/ProseMirror JSON document is detected and stored unchanged. Markdown is intended for plain content: if the field already holds rich blocks that Markdown cannot represent (embedded contentlets, video or layout blocks), the Markdown value is ignored and the existing document is preserved — to modify such a field, send a full Tiptap/ProseMirror JSON document. Example: `"body": "## Intro\n\nHello **world**."`.
operationId: putFireActionByName
parameters:
- description: Inode of the target content.
Expand Down Expand Up @@ -19086,7 +19086,7 @@ paths:

**When chaining workflow actions or reading state back immediately after firing, pass `indexPolicy=WAIT_FOR` on each call.** The default `DEFER` is asynchronous and can return stale index reads for several seconds, which can mimic server-side state bugs. For isolated one-off fires where nothing reads the result, leave the default.

**Block Editor (Story Block) fields:** send the value as an HTML or Markdown string — do not hand-author the underlying ProseMirror/JSON document. The value is stored as-is and converted to the Block Editor structure when the contentlet is opened in the editor. Example: `"body": "<h2>Intro</h2><p>Hello <strong>world</strong>.</p>"`.
**Block Editor (Story Block) fields:** send the value as a **Markdown** string — you do not need to hand-author the underlying ProseMirror/JSON document. dotCMS converts it to the Block Editor (ProseMirror JSON) structure automatically on save, so the field reads back as structured content with no editor round-trip required. A value that is already a valid Tiptap/ProseMirror JSON document is detected and stored unchanged. Markdown is intended for plain content: if the field already holds rich blocks that Markdown cannot represent (embedded contentlets, video or layout blocks), the Markdown value is ignored and the existing document is preserved — to modify such a field, send a full Tiptap/ProseMirror JSON document. Example: `"body": "## Intro\n\nHello **world**."`.
operationId: putFireActionByNameMultipart
parameters:
- description: Inode of the target content.
Expand Down Expand Up @@ -19519,7 +19519,7 @@ paths:

**When chaining workflow actions or reading state back immediately after firing, pass `indexPolicy=WAIT_FOR` on each call.** The default `DEFER` is asynchronous and can return stale index reads for several seconds, which can mimic server-side state bugs. For isolated one-off fires where nothing reads the result, leave the default.

**Block Editor (Story Block) fields:** send the value as an HTML or Markdown string — do not hand-author the underlying ProseMirror/JSON document. The value is stored as-is and converted to the Block Editor structure when the contentlet is opened in the editor. Example: `"body": "<h2>Intro</h2><p>Hello <strong>world</strong>.</p>"`.
**Block Editor (Story Block) fields:** send the value as a **Markdown** string — you do not need to hand-author the underlying ProseMirror/JSON document. dotCMS converts it to the Block Editor (ProseMirror JSON) structure automatically on save, so the field reads back as structured content with no editor round-trip required. A value that is already a valid Tiptap/ProseMirror JSON document is detected and stored unchanged. Markdown is intended for plain content: if the field already holds rich blocks that Markdown cannot represent (embedded contentlets, video or layout blocks), the Markdown value is ignored and the existing document is preserved — to modify such a field, send a full Tiptap/ProseMirror JSON document. Example: `"body": "## Intro\n\nHello **world**."`.
operationId: putFireActionById
parameters:
- description: |-
Expand Down Expand Up @@ -19625,7 +19625,7 @@ paths:

**When chaining workflow actions or reading state back immediately after firing, pass `indexPolicy=WAIT_FOR` on each call.** The default `DEFER` is asynchronous and can return stale index reads for several seconds, which can mimic server-side state bugs. For isolated one-off fires where nothing reads the result, leave the default.

**Block Editor (Story Block) fields:** send the value as an HTML or Markdown string — do not hand-author the underlying ProseMirror/JSON document. The value is stored as-is and converted to the Block Editor structure when the contentlet is opened in the editor. Example: `"body": "<h2>Intro</h2><p>Hello <strong>world</strong>.</p>"`.
**Block Editor (Story Block) fields:** send the value as a **Markdown** string — you do not need to hand-author the underlying ProseMirror/JSON document. dotCMS converts it to the Block Editor (ProseMirror JSON) structure automatically on save, so the field reads back as structured content with no editor round-trip required. A value that is already a valid Tiptap/ProseMirror JSON document is detected and stored unchanged. Markdown is intended for plain content: if the field already holds rich blocks that Markdown cannot represent (embedded contentlets, video or layout blocks), the Markdown value is ignored and the existing document is preserved — to modify such a field, send a full Tiptap/ProseMirror JSON document. Example: `"body": "## Intro\n\nHello **world**."`.
operationId: putFireActionByIdMultipart
parameters:
- description: |-
Expand Down
Loading
Loading