Skip to content

Commit 7028ef2

Browse files
authored
convert Block Editor HTML to ProseMirror JSON on save (server-side) (dotCMS#36473)
## What Converts Story Block (Block Editor) field values sent as **HTML** to Tiptap/ProseMirror JSON server-side, on the shared content save path — completing the HTML criterion deferred from dotCMS#36002. Non-interactive clients (AI agents, headless imports) no longer need a human to open and re-save the contentlet for an HTML value to read back as structured content. ## How - New `TiptapHtml` converter (jsoup DOM walk) — same node/mark shapes and primitive whitelist as `TiptapMarkdown`, so both paths produce documents the Block Editor loads identically. Sanitizes untrusted HTML: drops `script`/`style`/`iframe`/… subtrees, allow-lists attributes, scheme-checks `href`/`src`. - Wired into `MapToContentletPopulator`: `{` → JSON passthrough, an HTML tag → `TiptapHtml`, else → `TiptapMarkdown`. Reuses dotCMS#36002's rich-content overwrite guard and never-block-the-save fallback. - Adds `org.jsoup:jsoup 1.21.1`; OpenAPI fire note regenerated ("Markdown or HTML"). ## Behavior change An HTML value is now converted and stored as structured JSON instead of verbatim — only when a client resends the field; stored values are never rewritten on their own. Additive and rollback-safe. ## Testing 27 unit (`TiptapHtmlTest`) + 9 integration (`StoryBlockMarkdownPopulatorTest`, including HTML → GraphQL read-back and an editor-saved primitive doc updated via HTML end to end. Existing `TiptapMarkdown` tests unchanged. Closes dotCMS#36470
1 parent 7648742 commit 7028ef2

8 files changed

Lines changed: 1343 additions & 41 deletions

File tree

bom/application/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
<opensearch.version>3.3.0</opensearch.version>
3232
<langchain4j.version>1.15.1</langchain4j.version>
3333
<commonmark.version>0.22.0</commonmark.version>
34+
<jsoup.version>1.21.1</jsoup.version>
3435
</properties>
3536
<dependencyManagement>
3637

@@ -499,6 +500,11 @@
499500
<artifactId>commonmark-ext-gfm-strikethrough</artifactId>
500501
<version>${commonmark.version}</version>
501502
</dependency>
503+
<dependency>
504+
<groupId>org.jsoup</groupId>
505+
<artifactId>jsoup</artifactId>
506+
<version>${jsoup.version}</version>
507+
</dependency>
502508
<dependency>
503509
<groupId>com.dotcms.lib</groupId>
504510
<artifactId>dot.util-taglib</artifactId>

dotCMS/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,10 @@
403403
<groupId>org.commonmark</groupId>
404404
<artifactId>commonmark-ext-gfm-strikethrough</artifactId>
405405
</dependency>
406+
<dependency>
407+
<groupId>org.jsoup</groupId>
408+
<artifactId>jsoup</artifactId>
409+
</dependency>
406410
<dependency>
407411
<groupId>com.dotcms.lib</groupId>
408412
<artifactId>dot.util-taglib</artifactId>

dotCMS/src/main/java/com/dotcms/rest/MapToContentletPopulator.java

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.dotcms.contenttype.model.type.ContentType;
1111
import com.dotcms.contenttype.transform.field.LegacyFieldTransformer;
1212
import com.dotcms.rest.api.v1.temp.DotTempFile;
13+
import com.dotcms.tiptap.TiptapHtml;
1314
import com.dotcms.tiptap.TiptapMarkdown;
1415
import com.dotcms.util.CollectionsUtils;
1516
import com.dotcms.util.DotPreconditions;
@@ -40,6 +41,7 @@
4041
import com.dotmarketing.util.Logger;
4142
import com.dotmarketing.util.UtilMethods;
4243
import com.dotmarketing.util.json.JSONObject;
44+
import com.fasterxml.jackson.databind.node.ObjectNode;
4345
import com.google.common.collect.ImmutableList;
4446
import com.google.common.collect.ImmutableMap;
4547
import com.google.common.collect.ImmutableSet;
@@ -56,6 +58,7 @@
5658
import java.util.*;
5759
import java.util.Map.Entry;
5860
import java.util.function.LongSupplier;
61+
import java.util.regex.Pattern;
5962
import java.util.stream.Collectors;
6063

6164
import 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,

dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,13 +228,13 @@ public class WorkflowResource {
228228
"leave the default.";
229229

230230
private static final String BLOCK_EDITOR_FIELD_NOTE =
231-
"\n\n**Block Editor (Story Block) fields:** send the value as a **Markdown** string — " +
231+
"\n\n**Block Editor (Story Block) fields:** send the value as a **Markdown or HTML** string — " +
232232
"you do not need to hand-author the underlying ProseMirror/JSON document. dotCMS converts it " +
233233
"to the Block Editor (ProseMirror JSON) structure automatically on save, so the field reads " +
234234
"back as structured content with no editor round-trip required. A value that is already a " +
235-
"valid Tiptap/ProseMirror JSON document is detected and stored unchanged. Markdown is intended " +
236-
"for plain content: if the field already holds rich blocks that Markdown cannot represent " +
237-
"(embedded contentlets, video or layout blocks), the Markdown value is ignored and the existing " +
235+
"valid Tiptap/ProseMirror JSON document is detected and stored unchanged. Markdown and HTML are " +
236+
"intended for plain content: if the field already holds rich blocks that they cannot represent " +
237+
"(embedded contentlets, video or layout blocks), the value is ignored and the existing " +
238238
"document is preserved — to modify such a field, send a full Tiptap/ProseMirror JSON document. " +
239239
"Example: `\"body\": \"## Intro\\n\\nHello **world**.\"`.";
240240

0 commit comments

Comments
 (0)