Skip to content
Open
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
55 changes: 55 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,56 @@ 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).
// Refuse to overwrite a stored document that contains any of them rather than silently
// dropping content; such updates must send a full Tiptap JSON document.
final String existing = contentlet.getStringProperty(field.getVelocityVarName());
if (TiptapMarkdown.isTiptapDoc(existing) && !TiptapMarkdown.isMarkdownRepresentable(existing)) {
throw new IllegalArgumentException(String.format(
"Story Block field [%s] contains rich content (e.g. embedded contentlets, "
+ "video or layout blocks) that Markdown cannot represent. Send a full "
+ "Tiptap/ProseMirror JSON document to update this field.",
field.getVelocityVarName()));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Isn't this a bit too aggressive?? Perhaps all we need to do is log the error, but not interrupt the application flow

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Perhaps all we need to do here is log the error or a Warning

}

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,12 @@ 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. " +
"Example: `\"body\": \"## Intro\\n\\nHello **world**.\"`.";

private static final String BULK_FIRE_CONTRACT_NOTES =
"⚠️ **Important contract notes:**\n\n" +
Expand Down
72 changes: 72 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,78 @@ 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 non-whitespace character is 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.stripLeading();
if (trimmed.isEmpty() || trimmed.charAt(0) != '{') {
return false;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

test first, and the last chars seem a bit more like a solid test

}
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 refuse 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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

or empty

return true;
}
try {
return isMarkdownRepresentable(MAPPER.readTree(tiptapJson));
} catch (final java.io.IOException e) {
return true;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we add some logging here ? in case of a failure

}

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 @@ -18672,7 +18672,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. Example: `"body": "## Intro\n\nHello **world**."`.
operationId: putFireDefaultSystemAction
parameters:
- description: Inode of the target content.
Expand Down Expand Up @@ -18810,7 +18810,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. Example: `"body": "## Intro\n\nHello **world**."`.
operationId: putFireDefaultActionMultipart
parameters:
- description: Inode of the target content.
Expand Down Expand Up @@ -18902,7 +18902,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. Example: `"body": "## Intro\n\nHello **world**."`.
operationId: putFireActionByName
parameters:
- description: Inode of the target content.
Expand Down Expand Up @@ -19001,7 +19001,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. Example: `"body": "## Intro\n\nHello **world**."`.
operationId: putFireActionByNameMultipart
parameters:
- description: Inode of the target content.
Expand Down Expand Up @@ -19434,7 +19434,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. Example: `"body": "## Intro\n\nHello **world**."`.
operationId: putFireActionById
parameters:
- description: |-
Expand Down Expand Up @@ -19540,7 +19540,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. Example: `"body": "## Intro\n\nHello **world**."`.
operationId: putFireActionByIdMultipart
parameters:
- description: |-
Expand Down
Loading
Loading