From 9a96cc0768aaecc68065877063e600669fbf5b55 Mon Sep 17 00:00:00 2001 From: hassandotcms Date: Mon, 22 Jun 2026 18:59:45 +0500 Subject: [PATCH 1/6] feat(story-block): add isTiptapDoc and isMarkdownRepresentable discriminators Add two pure helpers to TiptapMarkdown that the save path needs to safely ingest Story Block values: - isTiptapDoc(String): cheap detector for an already-valid Tiptap/ProseMirror document (peeks the first non-whitespace char before parsing), so editor- authored JSON can be stored unchanged instead of re-parsed as Markdown. - isMarkdownRepresentable(String): true only when every block is Markdown- expressible, used to refuse a Markdown overwrite that would silently drop rich blocks (dotContent, dotVideo, grid, etc.). Marks are ignored on purpose (losing a mark loses styling, not content). Covered by TiptapMarkdownDocDetectionTest (13 cases incl. nested rich blocks, marks-only docs, malformed/empty/null input). Refs #36002 --- .../com/dotcms/tiptap/TiptapMarkdown.java | 72 +++++++++++ .../TiptapMarkdownDocDetectionTest.java | 117 ++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 dotCMS/src/test/java/com/dotcms/tiptap/TiptapMarkdownDocDetectionTest.java diff --git a/dotCMS/src/main/java/com/dotcms/tiptap/TiptapMarkdown.java b/dotCMS/src/main/java/com/dotcms/tiptap/TiptapMarkdown.java index ffacc2a55583..6bc93147b98a 100644 --- a/dotCMS/src/main/java/com/dotcms/tiptap/TiptapMarkdown.java +++ b/dotCMS/src/main/java/com/dotcms/tiptap/TiptapMarkdown.java @@ -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; + } + 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 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) { + return true; + } + try { + return isMarkdownRepresentable(MAPPER.readTree(tiptapJson)); + } catch (final java.io.IOException e) { + return true; + } + } + + 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) // ===================================================================== diff --git a/dotCMS/src/test/java/com/dotcms/tiptap/TiptapMarkdownDocDetectionTest.java b/dotCMS/src/test/java/com/dotcms/tiptap/TiptapMarkdownDocDetectionTest.java new file mode 100644 index 000000000000..02e240facc52 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/tiptap/TiptapMarkdownDocDetectionTest.java @@ -0,0 +1,117 @@ +package com.dotcms.tiptap; + +import com.dotcms.UnitTestBase; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for the #36002 save-path discriminators added to {@link TiptapMarkdown}: + * {@link TiptapMarkdown#isTiptapDoc(String)} (AC: "already valid Tiptap JSON is detected and + * stored unchanged") and {@link TiptapMarkdown#isMarkdownRepresentable(String)} (the + * whitelist that powers the rich-content overwrite guard). + * + * @author hassandotcms + */ +public class TiptapMarkdownDocDetectionTest extends UnitTestBase { + + // ---- isTiptapDoc ---------------------------------------------------- + + @Test + public void isTiptapDoc_true_for_doc_with_content() { + assertTrue(TiptapMarkdown.isTiptapDoc( + "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}")); + } + + @Test + public void isTiptapDoc_true_for_empty_doc() { + // The editor serializes an empty Story Block as a doc with an empty content array. + assertTrue(TiptapMarkdown.isTiptapDoc("{\"type\":\"doc\",\"content\":[]}")); + } + + @Test + public void isTiptapDoc_true_when_leading_whitespace() { + assertTrue(TiptapMarkdown.isTiptapDoc(" \n {\"type\":\"doc\",\"content\":[]}")); + } + + @Test + public void isTiptapDoc_false_for_doc_without_content_array() { + // type=doc but no content array -> not a usable document, must not pass through as one. + assertFalse(TiptapMarkdown.isTiptapDoc("{\"type\":\"doc\"}")); + assertFalse(TiptapMarkdown.isTiptapDoc("{\"type\":\"doc\",\"content\":\"x\"}")); + } + + @Test + public void isTiptapDoc_false_for_arbitrary_or_non_doc_json() { + assertFalse(TiptapMarkdown.isTiptapDoc("{\"foo\":\"bar\"}")); + assertFalse(TiptapMarkdown.isTiptapDoc("{\"type\":\"paragraph\",\"content\":[]}")); + } + + @Test + public void isTiptapDoc_false_for_markdown_html_and_blanks() { + assertFalse(TiptapMarkdown.isTiptapDoc("# Heading\n\nHello **world**.")); + assertFalse(TiptapMarkdown.isTiptapDoc("

Heading

")); + assertFalse(TiptapMarkdown.isTiptapDoc("")); + assertFalse(TiptapMarkdown.isTiptapDoc(" ")); + assertFalse(TiptapMarkdown.isTiptapDoc(null)); + } + + @Test + public void isTiptapDoc_false_for_malformed_json() { + assertFalse(TiptapMarkdown.isTiptapDoc("{\"type\":\"doc\",\"content\":[")); + } + + // ---- isMarkdownRepresentable --------------------------------------- + + @Test + public void representable_true_for_primitive_blocks() { + final String doc = "{\"type\":\"doc\",\"content\":[" + + "{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"type\":\"text\",\"text\":\"H\"}]}," + + "{\"type\":\"bulletList\",\"content\":[{\"type\":\"listItem\",\"content\":[" + + "{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"x\"}]}]}]}," + + "{\"type\":\"dotImage\",\"attrs\":{\"src\":\"/a.png\"}}]}"; + assertTrue(TiptapMarkdown.isMarkdownRepresentable(doc)); + } + + @Test + public void representable_true_when_only_marks_are_unsupported() { + // Underline has no Markdown, but it's a mark, not a block — losing it is acceptable, + // so it must NOT trip the overwrite guard. + final String doc = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[" + + "{\"type\":\"text\",\"text\":\"x\",\"marks\":[{\"type\":\"underline\"}]}]}]}"; + assertTrue(TiptapMarkdown.isMarkdownRepresentable(doc)); + } + + @Test + public void representable_false_for_embedded_contentlet() { + final String doc = "{\"type\":\"doc\",\"content\":[" + + "{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"x\"}]}," + + "{\"type\":\"dotContent\",\"attrs\":{\"data\":{\"title\":\"t\"}}}]}"; + assertFalse(TiptapMarkdown.isMarkdownRepresentable(doc)); + } + + @Test + public void representable_false_for_rich_blocks() { + for (final String richType : new String[]{"dotVideo", "youtube", "gridBlock", "aiContent"}) { + final String doc = "{\"type\":\"doc\",\"content\":[{\"type\":\"" + richType + "\"}]}"; + assertFalse("'" + richType + "' must be flagged as not representable", + TiptapMarkdown.isMarkdownRepresentable(doc)); + } + } + + @Test + public void representable_false_when_rich_block_is_nested() { + // A rich block buried inside a list item must still be detected. + final String doc = "{\"type\":\"doc\",\"content\":[{\"type\":\"bulletList\",\"content\":[" + + "{\"type\":\"listItem\",\"content\":[{\"type\":\"dotVideo\"}]}]}]}"; + assertFalse(TiptapMarkdown.isMarkdownRepresentable(doc)); + } + + @Test + public void representable_true_for_null_and_non_json() { + // Nothing structured to protect. + assertTrue(TiptapMarkdown.isMarkdownRepresentable(null)); + assertTrue(TiptapMarkdown.isMarkdownRepresentable("just some legacy text")); + } +} From a47d91acb05c86f1711be7864fe2486629194691 Mon Sep 17 00:00:00 2001 From: hassandotcms Date: Mon, 22 Jun 2026 18:59:55 +0500 Subject: [PATCH 2/6] feat(story-block): convert Markdown to ProseMirror JSON on the content save path Wire the converter into MapToContentletPopulator.fillFields, the shared seam that the workflow fire endpoints and the content REST API all funnel through. For a Story Block field whose incoming value is Markdown (begins with neither '{' nor '<'), convert it to a ProseMirror JSON document and store that, so non- interactive clients (AI agents, headless imports) no longer require a human to open and re-save the contentlet. Guards: - Already-valid Tiptap JSON and (deferred) HTML are stored unchanged. - A Markdown update is refused when the existing stored document contains rich blocks Markdown cannot represent, rather than silently destroying them. - A conversion failure never blocks the save: the raw value is stored and a warning logged (graceful degradation, consistent with #35728). The converter stays pure; conversion and guards live at the ingestion seam. Covered by StoryBlockMarkdownPopulatorTest (convert + GraphQL read-back, JSON passthrough, HTML passthrough, primitive replace, rich-overwrite reject); registered in MainSuite1b. Refs #36002 --- .../dotcms/rest/MapToContentletPopulator.java | 55 ++++++ .../src/test/java/com/dotcms/MainSuite1b.java | 3 +- .../rest/StoryBlockMarkdownPopulatorTest.java | 176 ++++++++++++++++++ 3 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 dotcms-integration/src/test/java/com/dotcms/rest/StoryBlockMarkdownPopulatorTest.java diff --git a/dotCMS/src/main/java/com/dotcms/rest/MapToContentletPopulator.java b/dotCMS/src/main/java/com/dotcms/rest/MapToContentletPopulator.java index ca96b7a7e6c4..d04a2ebf2009 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/MapToContentletPopulator.java +++ b/dotCMS/src/main/java/com/dotcms/rest/MapToContentletPopulator.java @@ -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; @@ -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); @@ -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())); + } + + 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 map, final Field field, final Object value, diff --git a/dotcms-integration/src/test/java/com/dotcms/MainSuite1b.java b/dotcms-integration/src/test/java/com/dotcms/MainSuite1b.java index b1e5bf853a22..a3d438835713 100644 --- a/dotcms-integration/src/test/java/com/dotcms/MainSuite1b.java +++ b/dotcms-integration/src/test/java/com/dotcms/MainSuite1b.java @@ -95,7 +95,8 @@ com.dotcms.content.elasticsearch.business.ES6UpgradeTest.class, com.dotcms.content.elasticsearch.business.ESContentFactoryImplTest.class, com.dotcms.graphql.datafetcher.page.ContentMapDataFetcherTest.class, - com.dotcms.graphql.datafetcher.RelationshipFieldDataFetcherTest.class + com.dotcms.graphql.datafetcher.RelationshipFieldDataFetcherTest.class, + com.dotcms.rest.StoryBlockMarkdownPopulatorTest.class }) public class MainSuite1b { diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/StoryBlockMarkdownPopulatorTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/StoryBlockMarkdownPopulatorTest.java new file mode 100644 index 000000000000..1b29ffd913a9 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/rest/StoryBlockMarkdownPopulatorTest.java @@ -0,0 +1,176 @@ +package com.dotcms.rest; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.dotcms.IntegrationTestBase; +import com.dotcms.contenttype.model.field.Field; +import com.dotcms.contenttype.model.field.ImmutableStoryBlockField; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.datagen.ContentTypeDataGen; +import com.dotcms.datagen.ContentletDataGen; +import com.dotcms.graphql.datafetcher.StoryBlockFieldDataFetcher; +import com.dotcms.tiptap.TiptapMarkdown; +import com.dotcms.util.IntegrationTestInitService; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.util.UtilMethods; +import com.liferay.portal.model.User; +import graphql.schema.DataFetchingEnvironment; +import java.util.HashMap; +import java.util.Map; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.Mockito; + +/** + * Integration tests for the #36002 Story Block save path: the shared ingestion seam + * ({@link MapToContentletPopulator#populate}) converts a Markdown Story Block value to + * Tiptap/ProseMirror JSON, leaves already-JSON and (deferred) HTML untouched, and refuses a + * Markdown overwrite that would destroy rich content. + * + * @author hassandotcms + */ +public class StoryBlockMarkdownPopulatorTest extends IntegrationTestBase { + + private static final String STORY_BLOCK_VAR = "body"; + + private static User systemUser; + private static ContentType contentType; + + @BeforeClass + public static void prepare() throws Exception { + IntegrationTestInitService.getInstance().init(); + systemUser = APILocator.getUserAPI().getSystemUser(); + + contentType = new ContentTypeDataGen() + .name("StoryBlockMarkdownPopulatorTest_" + System.currentTimeMillis()) + .nextPersisted(); + + Field storyBlockField = ImmutableStoryBlockField.builder() + .name("Body") + .variable(STORY_BLOCK_VAR) + .contentTypeId(contentType.id()) + .required(false) + .build(); + APILocator.getContentTypeFieldAPI().save(storyBlockField, systemUser); + } + + @AfterClass + public static void cleanup() throws Exception { + if (UtilMethods.isSet(contentType) && UtilMethods.isSet(contentType.id())) { + APILocator.getContentTypeAPI(systemUser).delete(contentType); + } + } + + private Contentlet newContentlet() { + final Contentlet contentlet = new Contentlet(); + contentlet.setContentTypeId(contentType.id()); + return contentlet; + } + + private Map propsWith(final String storyBlockValue) { + final Map props = new HashMap<>(); + props.put(Contentlet.STRUCTURE_INODE_KEY, contentType.id()); + props.put(STORY_BLOCK_VAR, storyBlockValue); + return props; + } + + /** + * The defining acceptance test (#36002 AC #4): Markdown supplied on the save path is + * converted, persisted through {@code checkin}, and READS BACK as structured ProseMirror + * JSON — with no human editor round-trip. This exercises the full seam end to end: + * populator conversion -> Story Block checkin validation -> store -> re-read from the DB. + */ + @Test + public void markdown_fired_reads_back_as_prosemirror_json() throws Exception { + // The base contentlet carries host/folder/language so checkin runs realistically; the + // Markdown body is applied (and converted) through the populator that is under test. + final Contentlet base = new ContentletDataGen(contentType.id()).next(); + final Contentlet populated = new MapToContentletPopulator() + .populate(base, propsWith("## Title\n\nHello **world**.")); + + final Contentlet saved = APILocator.getContentletAPI().checkin(populated, systemUser, false); + final Contentlet readBack = APILocator.getContentletAPI() + .find(saved.getInode(), systemUser, false); + + final String stored = readBack.getStringProperty(STORY_BLOCK_VAR); + assertTrue("Field must read back as a Tiptap doc", TiptapMarkdown.isTiptapDoc(stored)); + assertTrue("Heading structure must survive the round-trip", stored.contains("\"heading\"")); + assertTrue("Text must survive the round-trip", stored.contains("world")); + + // Confirm it surfaces through the GraphQL read path that headless clients consume + // (DotStoryBlock.json). StoryBlockFieldDataFetcher parses the stored value as JSON, so a + // raw-Markdown value would make this throw — the exact pre-#36002 "reads back broken" bug. + final DataFetchingEnvironment env = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(env.getSource()).thenReturn(readBack); + Mockito.when(env.getField()).thenReturn(new graphql.language.Field(STORY_BLOCK_VAR)); + + final Map fetched = new StoryBlockFieldDataFetcher().get(env); + final Object json = fetched.get("json"); + assertTrue("GraphQL must return a structured JSON object", json instanceof Map); + assertEquals("Must read back as a ProseMirror doc", "doc", ((Map) json).get("type")); + } + + /** Already-valid Tiptap JSON (the dominant editor traffic) is stored byte-identical. */ + @Test + public void existing_prosemirror_json_passes_through_unchanged() { + final String json = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"," + + "\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}"; + + final Contentlet result = new MapToContentletPopulator() + .populate(newContentlet(), propsWith(json)); + + assertEquals(json, result.getStringProperty(STORY_BLOCK_VAR)); + } + + /** HTML is deferred (#36002 follow-up): stored as-is, never mangled — no regression. */ + @Test + public void legacy_html_passes_through_unchanged() { + final String html = "

legacy WYSIWYG content

"; + + final Contentlet result = new MapToContentletPopulator() + .populate(newContentlet(), propsWith(html)); + + assertEquals(html, result.getStringProperty(STORY_BLOCK_VAR)); + } + + /** Markdown may replace a primitive-only document. */ + @Test + public void markdown_replaces_primitive_only_document() { + final Contentlet contentlet = newContentlet(); + contentlet.setProperty(STORY_BLOCK_VAR, "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"," + + "\"content\":[{\"type\":\"text\",\"text\":\"old\"}]}]}"); + + final Contentlet result = new MapToContentletPopulator() + .populate(contentlet, propsWith("# Brand new")); + + final String stored = result.getStringProperty(STORY_BLOCK_VAR); + assertTrue(TiptapMarkdown.isTiptapDoc(stored)); + assertTrue("New content present", stored.contains("Brand new")); + assertFalse("Old content replaced", stored.contains("old")); + } + + /** Markdown must NOT clobber a document containing rich blocks; the existing doc is preserved. */ + @Test + public void markdown_overwrite_of_rich_content_is_rejected() { + final String richDoc = "{\"type\":\"doc\",\"content\":[{\"type\":\"dotContent\"," + + "\"attrs\":{\"data\":{\"title\":\"Embedded\"}}}]}"; + final Contentlet contentlet = newContentlet(); + contentlet.setProperty(STORY_BLOCK_VAR, richDoc); + + try { + new MapToContentletPopulator().populate(contentlet, propsWith("# Trying to overwrite")); + fail("Expected an IllegalArgumentException rejecting the Markdown overwrite"); + } catch (final IllegalArgumentException e) { + assertTrue("Message should explain the rejection: " + e.getMessage(), + e.getMessage() != null && e.getMessage().contains("rich content")); + } + + assertEquals("Existing rich document must be untouched", richDoc, + contentlet.getStringProperty(STORY_BLOCK_VAR)); + } +} From ae1d0dc250288452519e2510a6e363b5cbbc9877 Mon Sep 17 00:00:00 2001 From: hassandotcms Date: Mon, 22 Jun 2026 19:00:02 +0500 Subject: [PATCH 3/6] docs(workflow): correct Block Editor fire note for server-side Markdown conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fire endpoints' Block Editor note promised Markdown/HTML acceptance but admitted it only took effect after a human re-saved in the editor — documenting the exact bug #36002 fixes. Update the shared @Operation note to state that Markdown is converted to ProseMirror JSON automatically on save (and already- valid JSON is stored unchanged), drop the "converted when opened in the editor" caveat, and use a Markdown example. Regenerate openapi.yaml (all 6 fire operations share the constant). Refs #36002 --- .../rest/api/v1/workflow/WorkflowResource.java | 10 ++++++---- dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml | 12 ++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java index 230bd5e4b8e1..29edfc926956 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java @@ -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\": \"

Intro

Hello world.

\"`."; + "\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" + diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index cddeb57eab69..34e23a5f86d1 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -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": "

Intro

Hello world.

"`. + **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. @@ -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": "

Intro

Hello world.

"`. + **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. @@ -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": "

Intro

Hello world.

"`. + **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. @@ -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": "

Intro

Hello world.

"`. + **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. @@ -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": "

Intro

Hello world.

"`. + **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: |- @@ -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": "

Intro

Hello world.

"`. + **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: |- From f01cb0abfd51e5e52aca10d9e83c0854410e5b80 Mon Sep 17 00:00:00 2001 From: hassandotcms Date: Tue, 23 Jun 2026 19:03:43 +0500 Subject: [PATCH 4/6] test(story-block): assert normalized ProseMirror body, not raw string #36002 normalizes a plain-text/Markdown Story Block value to a ProseMirror JSON document on save, so a webPageContent `body` sent as plain text now reads back as a structured doc (object), not the raw string. Update the two API tests that asserted the old raw-string round-trip to assert the normalized doc instead, keeping plain-text input so they still exercise the server-side conversion: - Karate CheckingJSONAttributes.feature: assert body.type == 'doc' and the paragraph text, instead of body == "". - Postman JsScriptAPI: assert body.type == 'doc' and that the surviving text segments are present (inline markup is dropped by the Markdown converter), across fireNew/fireEdit/firePublish via the JS workflows viewtool. Refs #36002 --- .../JsScriptAPI.postman_collection.json | 19 ++++++++++++++----- .../defaults/CheckingJSONAttributes.feature | 5 ++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/dotcms-postman/src/main/resources/postman/JsScriptAPI.postman_collection.json b/dotcms-postman/src/main/resources/postman/JsScriptAPI.postman_collection.json index f7e4a23619c8..411e9eba0c34 100644 --- a/dotcms-postman/src/main/resources/postman/JsScriptAPI.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/JsScriptAPI.postman_collection.json @@ -22,7 +22,8 @@ "pm.test(\"Checking the body\", function () {", " pm.expect(jsonData.contentType).to.eql('webPageContent');", " pm.expect(jsonData.title).to.eql('Test7');", - " pm.expect(jsonData.body).to.eql('This is a body text');", + " pm.expect(jsonData.body.type).to.eql('doc');", + " pm.expect(JSON.stringify(jsonData.body)).to.include('This is a body text');", "});", "", "pm.collectionVariables.set(\"contentletIdentifier\", jsonData.identifier);", @@ -90,7 +91,9 @@ "pm.test(\"Checking the body\", function () {", " pm.expect(jsonData.contentType).to.eql('webPageContent');", " pm.expect(jsonData.title).to.eql('Test3 has Changed');", - " pm.expect(jsonData.body).to.eql('This is a body text Changed');", + " pm.expect(jsonData.body.type).to.eql('doc');", + " pm.expect(JSON.stringify(jsonData.body)).to.include('This is a body text');", + " pm.expect(JSON.stringify(jsonData.body)).to.include('Changed');", "});", "", "pm.collectionVariables.set(\"contentletIdentifier\", jsonData.identifier);", @@ -158,7 +161,9 @@ "pm.test(\"Checking the body\", function () {", " pm.expect(jsonData.contentType).to.eql('webPageContent');", " pm.expect(jsonData.title).to.eql('Test3 has Changed');", - " pm.expect(jsonData.body).to.eql('This is a body text Changed');", + " pm.expect(jsonData.body.type).to.eql('doc');", + " pm.expect(JSON.stringify(jsonData.body)).to.include('This is a body text');", + " pm.expect(JSON.stringify(jsonData.body)).to.include('Changed');", "});", "", "pm.collectionVariables.set(\"contentletIdentifier\", jsonData.identifier);", @@ -235,7 +240,9 @@ "pm.test(\"Checking the body\", function () {", " pm.expect(jsonData.contentType).to.eql('webPageContent');", " pm.expect(jsonData.title).to.eql('Test3 has Changed');", - " pm.expect(jsonData.body).to.eql('This is a body text Changed');", + " pm.expect(jsonData.body.type).to.eql('doc');", + " pm.expect(JSON.stringify(jsonData.body)).to.include('This is a body text');", + " pm.expect(JSON.stringify(jsonData.body)).to.include('Changed');", "});", "", "pm.collectionVariables.set(\"contentletIdentifier\", jsonData.identifier);", @@ -312,7 +319,9 @@ "pm.test(\"Checking the body\", function () {", " pm.expect(jsonData.contentType).to.eql('webPageContent');", " pm.expect(jsonData.title).to.eql('Test3 Published');", - " pm.expect(jsonData.body).to.eql('This is a body text Has been published');", + " pm.expect(jsonData.body.type).to.eql('doc');", + " pm.expect(JSON.stringify(jsonData.body)).to.include('This is a body text');", + " pm.expect(JSON.stringify(jsonData.body)).to.include('Has been published');", "});", "", "pm.collectionVariables.set(\"contentletIdentifier\", jsonData.identifier);", diff --git a/test-karate/src/test/java/tests/defaults/CheckingJSONAttributes.feature b/test-karate/src/test/java/tests/defaults/CheckingJSONAttributes.feature index db269f176b39..a089d7c3425e 100644 --- a/test-karate/src/test/java/tests/defaults/CheckingJSONAttributes.feature +++ b/test-karate/src/test/java/tests/defaults/CheckingJSONAttributes.feature @@ -57,7 +57,6 @@ Feature: Checking JSON Attributes "contentType": "webPageContent", "title": "Test Generic Content", "hostName": "default", - "body": "This is my Test Generic Content", "creationDate": "#notnull", "owner": "#notnull", "ownerUserName": "#notnull", @@ -69,6 +68,10 @@ Feature: Checking JSON Attributes "publishUserName": "#notnull" } """ + # Story Block fields are normalized to a Tiptap/ProseMirror doc on save (#36002): a plain + # string body is stored and read back as structured JSON, not the raw string. + And match response.entity.body.type == 'doc' + And match response.entity.body.content[0].content[0].text == 'This is my Test Generic Content' Scenario Outline: Testing Content Creation Validation Given url baseUrl + '/api/v1/workflow/actions/default/fire/PUBLISH' From c6a9c38dc8d3a2946739f2bc97614cbd4415c9a4 Mon Sep 17 00:00:00 2001 From: hassandotcms Date: Tue, 23 Jun 2026 20:41:44 +0500 Subject: [PATCH 5/6] test(story-block): assert normalized body in BringBack/Versionable content checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI surfaced two more API tests that asserted a webPageContent `body` (a Story Block field) read back as the raw plain-text string. #36002 now normalizes plain-text to a ProseMirror doc on save, so the field comes back as structured JSON. Update only the content (webPageContent) assertions to read the text from the doc — body.content[0].content[0].text — leaving the template `body` assertions (template markup, not a Story Block field) untouched: - BringBack: 3 content version checks (create/edit/bring-back). - VersionableResource: 1 content working-version check. Determined the complete affected set by parsing every collection's body assertions against its request endpoint (content vs template), so template, GraphQL seeded/bundle, and response-body assertions are correctly excluded. Refs #36002 --- .../resources/postman/BringBack.postman_collection.json | 6 +++--- .../postman/VersionableResource.postman_collection.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dotcms-postman/src/main/resources/postman/BringBack.postman_collection.json b/dotcms-postman/src/main/resources/postman/BringBack.postman_collection.json index 3d88182e4f4d..ca65306f2d8e 100644 --- a/dotcms-postman/src/main/resources/postman/BringBack.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/BringBack.postman_collection.json @@ -112,7 +112,7 @@ " var jsonData = pm.response.json();", " pm.expect(jsonData.entity.baseType).to.eql(\"CONTENT\");", " pm.expect(jsonData.entity.contentType).to.eql(\"webPageContent\");", - " pm.expect(jsonData.entity.body).to.eql(\"Test content second version.\");", + " pm.expect(jsonData.entity.body.content[0].content[0].text).to.eql(\"Test content second version.\");", "});", "" ], @@ -189,7 +189,7 @@ " var jsonData = pm.response.json();", " pm.expect(jsonData.entity.baseType).to.eql(\"CONTENT\");", " pm.expect(jsonData.entity.contentType).to.eql(\"webPageContent\");", - " pm.expect(jsonData.entity.body).to.eql(\"Test content third version.\");", + " pm.expect(jsonData.entity.body.content[0].content[0].text).to.eql(\"Test content third version.\");", "});", "" ], @@ -325,7 +325,7 @@ " var jsonData = pm.response.json();", " pm.expect(jsonData.entity.baseType).to.eql(\"CONTENT\");", " pm.expect(jsonData.entity.contentType).to.eql(\"webPageContent\");", - " pm.expect(jsonData.entity.body).to.eql(\"Test content first version.\");", + " pm.expect(jsonData.entity.body.content[0].content[0].text).to.eql(\"Test content first version.\");", "});", "" ], diff --git a/dotcms-postman/src/main/resources/postman/VersionableResource.postman_collection.json b/dotcms-postman/src/main/resources/postman/VersionableResource.postman_collection.json index 9d82751f0833..7828d5424f56 100644 --- a/dotcms-postman/src/main/resources/postman/VersionableResource.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/VersionableResource.postman_collection.json @@ -408,7 +408,7 @@ " var jsonData = pm.response.json();", " pm.expect(jsonData.entity.baseType).to.eql(\"CONTENT\");", " pm.expect(jsonData.entity.contentType).to.eql(\"webPageContent\");", - " pm.expect(jsonData.entity.body).to.eql(\"test1\");", + " pm.expect(jsonData.entity.body.content[0].content[0].text).to.eql(\"test1\");", "});", "" ], From 146d824b91e16f3f5891c22ecc440df162bd984c Mon Sep 17 00:00:00 2001 From: hassandotcms Date: Thu, 25 Jun 2026 23:24:09 +0500 Subject: [PATCH 6/6] test(story-block): keep raw-string body assertions where body is not a Story Block field JsScriptAPI runs after Bundle_Resource imports a bundle that downgrades webPageContent.body to a WYSIWYG field, so its body reads back as the raw string (HTML), not a ProseMirror doc. VersionableResource runs in the default-split instance where webPageContent.body is likewise a plain string. In both cases the markdown->ProseMirror conversion correctly does not apply, so revert these two collections to their original raw-string assertions. BringBack (runs before the bundle downgrade) and the Karate CheckingJSONAttributes test (separate instance) still assert the normalized doc, since they hit a genuine Story Block field. --- .../JsScriptAPI.postman_collection.json | 19 +++++-------------- ...ersionableResource.postman_collection.json | 2 +- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/dotcms-postman/src/main/resources/postman/JsScriptAPI.postman_collection.json b/dotcms-postman/src/main/resources/postman/JsScriptAPI.postman_collection.json index 411e9eba0c34..f7e4a23619c8 100644 --- a/dotcms-postman/src/main/resources/postman/JsScriptAPI.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/JsScriptAPI.postman_collection.json @@ -22,8 +22,7 @@ "pm.test(\"Checking the body\", function () {", " pm.expect(jsonData.contentType).to.eql('webPageContent');", " pm.expect(jsonData.title).to.eql('Test7');", - " pm.expect(jsonData.body.type).to.eql('doc');", - " pm.expect(JSON.stringify(jsonData.body)).to.include('This is a body text');", + " pm.expect(jsonData.body).to.eql('This is a body text');", "});", "", "pm.collectionVariables.set(\"contentletIdentifier\", jsonData.identifier);", @@ -91,9 +90,7 @@ "pm.test(\"Checking the body\", function () {", " pm.expect(jsonData.contentType).to.eql('webPageContent');", " pm.expect(jsonData.title).to.eql('Test3 has Changed');", - " pm.expect(jsonData.body.type).to.eql('doc');", - " pm.expect(JSON.stringify(jsonData.body)).to.include('This is a body text');", - " pm.expect(JSON.stringify(jsonData.body)).to.include('Changed');", + " pm.expect(jsonData.body).to.eql('This is a body text Changed');", "});", "", "pm.collectionVariables.set(\"contentletIdentifier\", jsonData.identifier);", @@ -161,9 +158,7 @@ "pm.test(\"Checking the body\", function () {", " pm.expect(jsonData.contentType).to.eql('webPageContent');", " pm.expect(jsonData.title).to.eql('Test3 has Changed');", - " pm.expect(jsonData.body.type).to.eql('doc');", - " pm.expect(JSON.stringify(jsonData.body)).to.include('This is a body text');", - " pm.expect(JSON.stringify(jsonData.body)).to.include('Changed');", + " pm.expect(jsonData.body).to.eql('This is a body text Changed');", "});", "", "pm.collectionVariables.set(\"contentletIdentifier\", jsonData.identifier);", @@ -240,9 +235,7 @@ "pm.test(\"Checking the body\", function () {", " pm.expect(jsonData.contentType).to.eql('webPageContent');", " pm.expect(jsonData.title).to.eql('Test3 has Changed');", - " pm.expect(jsonData.body.type).to.eql('doc');", - " pm.expect(JSON.stringify(jsonData.body)).to.include('This is a body text');", - " pm.expect(JSON.stringify(jsonData.body)).to.include('Changed');", + " pm.expect(jsonData.body).to.eql('This is a body text Changed');", "});", "", "pm.collectionVariables.set(\"contentletIdentifier\", jsonData.identifier);", @@ -319,9 +312,7 @@ "pm.test(\"Checking the body\", function () {", " pm.expect(jsonData.contentType).to.eql('webPageContent');", " pm.expect(jsonData.title).to.eql('Test3 Published');", - " pm.expect(jsonData.body.type).to.eql('doc');", - " pm.expect(JSON.stringify(jsonData.body)).to.include('This is a body text');", - " pm.expect(JSON.stringify(jsonData.body)).to.include('Has been published');", + " pm.expect(jsonData.body).to.eql('This is a body text Has been published');", "});", "", "pm.collectionVariables.set(\"contentletIdentifier\", jsonData.identifier);", diff --git a/dotcms-postman/src/main/resources/postman/VersionableResource.postman_collection.json b/dotcms-postman/src/main/resources/postman/VersionableResource.postman_collection.json index 7828d5424f56..9d82751f0833 100644 --- a/dotcms-postman/src/main/resources/postman/VersionableResource.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/VersionableResource.postman_collection.json @@ -408,7 +408,7 @@ " var jsonData = pm.response.json();", " pm.expect(jsonData.entity.baseType).to.eql(\"CONTENT\");", " pm.expect(jsonData.entity.contentType).to.eql(\"webPageContent\");", - " pm.expect(jsonData.entity.body.content[0].content[0].text).to.eql(\"test1\");", + " pm.expect(jsonData.entity.body).to.eql(\"test1\");", "});", "" ],