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/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/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/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index 8a2da8bf0ccf..0cdee503e6da 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: |- 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")); + } +} 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)); + } +} 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/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'