1010import com .dotcms .contenttype .model .type .ContentType ;
1111import com .dotcms .contenttype .transform .field .LegacyFieldTransformer ;
1212import com .dotcms .rest .api .v1 .temp .DotTempFile ;
13+ import com .dotcms .tiptap .TiptapHtml ;
1314import com .dotcms .tiptap .TiptapMarkdown ;
1415import com .dotcms .util .CollectionsUtils ;
1516import com .dotcms .util .DotPreconditions ;
4041import com .dotmarketing .util .Logger ;
4142import com .dotmarketing .util .UtilMethods ;
4243import com .dotmarketing .util .json .JSONObject ;
44+ import com .fasterxml .jackson .databind .node .ObjectNode ;
4345import com .google .common .collect .ImmutableList ;
4446import com .google .common .collect .ImmutableMap ;
4547import com .google .common .collect .ImmutableSet ;
5658import java .util .*;
5759import java .util .Map .Entry ;
5860import java .util .function .LongSupplier ;
61+ import java .util .regex .Pattern ;
5962import java .util .stream .Collectors ;
6063
6164import static com .dotmarketing .portlets .contentlet .model .Contentlet .VARIANT_ID ;
@@ -78,6 +81,17 @@ public class MapToContentletPopulator {
7881 private static final String IDENTIFIER = "identifier" ;
7982 private static final String INDEX_POLICY = "indexPolicy" ;
8083
84+ /**
85+ * Matches a value that opens with a real HTML tag or comment, so a Story Block value can be
86+ * routed to the HTML converter. A leading {@code '<'} alone is not enough — a CommonMark autolink
87+ * ({@code <https://x>}) or text like {@code "<3"} also start with it and must go to Markdown; a
88+ * real tag name is ASCII letters/digits terminated by whitespace, {@code '/'} or {@code '>'}.
89+ * A leading {@code '<!'} (comment, {@code <!DOCTYPE>}, CDATA) is markup too, so a full HTML
90+ * document routes to the HTML converter rather than being mistaken for Markdown.
91+ */
92+ private static final Pattern HTML_START =
93+ Pattern .compile ("^<(!|/?[a-zA-Z][a-zA-Z0-9]*[\\ s/>])" );
94+
8195 @ CloseDBIfOpened
8296 public Contentlet populate (final Contentlet contentlet , final Map <String , Object > stringObjectMap ) {
8397
@@ -271,11 +285,10 @@ private void fillFields(final Contentlet contentlet,
271285
272286 /**
273287 * Story Block fields store a Tiptap/ProseMirror JSON document. Non-interactive clients
274- * (AI agents, headless imports) may instead send Markdown. We convert it to ProseMirror
275- * JSON here, on the shared save path, so the field reads back as structured content with
276- * no human editor round-trip. Values that are already JSON (the dominant editor traffic)
277- * or HTML are stored unchanged — Markdown is the only thing converted, and a conversion
278- * failure never blocks the save.
288+ * (AI agents, headless imports) may instead send Markdown or HTML. We convert either to
289+ * ProseMirror JSON here, on the shared save path, so the field reads back as structured
290+ * content with no human editor round-trip. Values that are already JSON (the dominant editor
291+ * traffic) are stored unchanged, and a conversion failure never blocks the save.
279292 */
280293 private void processStoryBlockField (final Contentlet contentlet , final Field field ,
281294 final String value ) {
@@ -287,41 +300,64 @@ private void processStoryBlockField(final Contentlet contentlet, final Field fie
287300 private String toStoryBlockJson (final Contentlet contentlet , final Field field ,
288301 final String value ) {
289302
290- // Editor-authored JSON and (for now) HTML are stored as-is; Markdown is plain text and
291- // begins with neither '{' nor '<' . This mirrors the Block Editor's own client-side
292- // routing and avoids re-parsing an existing document as Markdown .
303+ // Editor-authored JSON is stored as-is (it begins with '{'); a value that opens with an HTML
304+ // tag is converted from HTML, and anything else is treated as Markdown . This mirrors the Block
305+ // Editor's own client-side routing and avoids re-parsing an existing document.
293306 final String trimmed = value .stripLeading ();
294- if (trimmed .isEmpty () || trimmed .charAt (0 ) == '{' || trimmed . charAt ( 0 ) == '<' ) {
307+ if (trimmed .isEmpty () || trimmed .charAt (0 ) == '{' ) {
295308 return value ;
296309 }
310+ final boolean html = looksLikeHtml (trimmed );
297311
298- // Markdown cannot represent rich blocks (embedded contentlets, video, layout grids). Per the
299- // documented contract (see the fire endpoints' Block Editor note), Markdown is for plain
312+ // Markdown/HTML cannot represent rich blocks (embedded contentlets, video, layout grids). Per
313+ // the documented contract (see the fire endpoints' Block Editor note), they are for plain
300314 // content only and must not be used to modify a field that already holds such blocks. If that
301- // is attempted, keep the existing document untouched and log a warning — neither destroying
302- // the rich content nor failing the save. (Markdown -> rich merge is planned as a follow-up.)
315+ // is attempted, keep the existing document untouched and log a warning — neither destroying the
316+ // rich content nor failing the save. (A rich merge is planned as a follow-up.)
303317 final String existing = contentlet .getStringProperty (field .getVelocityVarName ());
304318 if (TiptapMarkdown .isTiptapDoc (existing ) && !TiptapMarkdown .isMarkdownRepresentable (existing )) {
305319 Logger .warn (this , String .format (
306- "Story Block field [%s] holds rich content that Markdown cannot represent; "
307- + "ignoring the Markdown value and keeping the existing document. Send a "
308- + "full Tiptap/ProseMirror JSON document to modify this field." ,
309- field .getVelocityVarName ()));
320+ "Story Block field [%s] holds rich content that %s cannot represent; ignoring the "
321+ + "value and keeping the existing document. Send a full Tiptap/ProseMirror "
322+ + "JSON document to modify this field." ,
323+ field .getVelocityVarName (), html ? "HTML" : "Markdown" ));
310324 return existing ;
311325 }
312326
313327 try {
314- return TiptapMarkdown .toTiptap (value ).toString ();
328+ final ObjectNode converted = html ? TiptapHtml .toTiptap (value ) : TiptapMarkdown .toTiptap (value );
329+ // Non-blank input whose content is entirely stripped (sanitization / unparseable) converts to
330+ // an empty document. Storing it would silently clear a field the client meant to populate and
331+ // wipe any prior value, so keep the existing value and warn instead. A genuine field-clear
332+ // arrives as a blank value (handled above), so this never blocks an intended clear.
333+ if (converted .path ("content" ).size () == 0 && UtilMethods .isSet (existing )) {
334+ Logger .warn (this , String .format (
335+ "Story Block field [%s]: %s input converted to an empty document; keeping the "
336+ + "existing value rather than clearing the field." ,
337+ field .getVelocityVarName (), html ? "HTML" : "Markdown" ));
338+ return existing ;
339+ }
340+ return converted .toString ();
315341 } catch (final Exception e ) {
316- // Graceful degradation (consistent with the converter's #35728 contract): a parse
317- // failure must never block the save — store the original value and move on.
342+ // Graceful degradation (consistent with the converters' contract): a conversion failure
343+ // must never block the save — store the original value and move on.
318344 Logger .warn (this , String .format (
319- "Story Block field [%s]: Markdown conversion failed, storing value unchanged. %s" ,
320- field .getVelocityVarName (), e .getMessage ()));
345+ "Story Block field [%s]: %s conversion failed, storing value unchanged. %s" ,
346+ field .getVelocityVarName (), html ? "HTML" : "Markdown" , e .getMessage ()));
321347 return value ;
322348 }
323349 }
324350
351+ /**
352+ * Distinguishes a genuine HTML fragment (an opening/closing tag or a comment) from a Markdown
353+ * value that merely starts with {@code '<'} — e.g. a CommonMark autolink {@code <https://x>} or
354+ * text like {@code "<3 things"} — which must be routed to the Markdown converter instead. The
355+ * argument is expected to be already left-trimmed.
356+ */
357+ private static boolean looksLikeHtml (final String trimmed ) {
358+ return HTML_START .matcher (trimmed ).find ();
359+ }
360+
325361 private static void processPlainValueForBinaryField (final Map <String , Object > map ,
326362 final Field field ,
327363 final Object value ,
0 commit comments