diff --git a/bom/application/pom.xml b/bom/application/pom.xml
index 968b7f5cf784..19580be8bbc0 100644
--- a/bom/application/pom.xml
+++ b/bom/application/pom.xml
@@ -29,6 +29,7 @@
1.13.10
3.3.0
1.0.0
+ 0.22.0
@@ -440,6 +441,21 @@
dot.txtmark
0.14-SNAPSHOT_1
+
+ org.commonmark
+ commonmark
+ ${commonmark.version}
+
+
+ org.commonmark
+ commonmark-ext-gfm-tables
+ ${commonmark.version}
+
+
+ org.commonmark
+ commonmark-ext-gfm-strikethrough
+ ${commonmark.version}
+
com.dotcms.lib
dot.util-taglib
diff --git a/dotCMS/pom.xml b/dotCMS/pom.xml
index 22386bf0013b..50f18ee3b71b 100644
--- a/dotCMS/pom.xml
+++ b/dotCMS/pom.xml
@@ -387,6 +387,18 @@
com.dotcms.lib
dot.txtmark
+
+ org.commonmark
+ commonmark
+
+
+ org.commonmark
+ commonmark-ext-gfm-tables
+
+
+ org.commonmark
+ commonmark-ext-gfm-strikethrough
+
com.dotcms.lib
dot.util-taglib
diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/MarkdownTool.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/MarkdownTool.java
index 3a3f3239f1b9..493fc8cde510 100755
--- a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/MarkdownTool.java
+++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/MarkdownTool.java
@@ -1,4 +1,7 @@
package com.dotcms.rendering.velocity.viewtools;
+import com.dotcms.tiptap.TiptapMarkdown;
+import com.dotcms.util.JsonUtil;
+import com.dotmarketing.util.json.JSONObject;
import java.io.StringWriter;
import javax.servlet.http.HttpServletRequest;
@@ -123,10 +126,32 @@ private String getFileContents(String path, boolean parseFirst) throws Throwable
}
}
-
-
-
-
+
+ /**
+ * parse a block of json to markdown
+ * @param parse
+ * @return
+ * @throws Throwable
+ */
+ public String blockToMarkdown(String parse) throws Throwable {
+ if (parse == null || parse.isEmpty()) {
+ return "";
+ }
+ if (JsonUtil.isValidJSON(parse)) {
+ return TiptapMarkdown.toMarkdown(parse);
+ }
+ return parse;
+ }
+
+ /**
+ * parse a block of json to markdown
+ * @param parse
+ * @return
+ * @throws Throwable
+ */
+ public String blockToMarkdown(JSONObject parse) throws Throwable {
+ return TiptapMarkdown.toMarkdown(parse);
+ }
}
diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/StoryBlockMap.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/StoryBlockMap.java
index 533bff52b6ba..d782274953a7 100644
--- a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/StoryBlockMap.java
+++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/StoryBlockMap.java
@@ -2,6 +2,7 @@
import com.dotcms.contenttype.transform.field.LegacyFieldTransformer;
import com.dotcms.rendering.velocity.viewtools.content.util.RenderableFactory;
+import com.dotcms.tiptap.TiptapMarkdown;
import com.dotcms.util.JsonUtil;
import com.dotmarketing.business.APILocator;
import com.dotmarketing.portlets.contentlet.model.Contentlet;
@@ -125,6 +126,17 @@ public String toHtml() {
return builder.toString();
}
+ /**
+ * Returns this Story Block's content as markdown. When the field holds raw HTML
+ * (no Tiptap JSON) it's returned unchanged.
+ */
+ public String toMarkdown() {
+ if (this.jsonContFieldValue == null) {
+ return UtilMethods.isSet(this.htmlContFieldValue) ? this.htmlContFieldValue : StringPool.BLANK;
+ }
+ return TiptapMarkdown.toMarkdown(this.jsonContFieldValue);
+ }
+
@Override
public String toHtml(final String baseTemplatePath) {
final StringBuilder builder = new StringBuilder();
diff --git a/dotCMS/src/main/java/com/dotcms/tiptap/TiptapMarkdown.java b/dotCMS/src/main/java/com/dotcms/tiptap/TiptapMarkdown.java
new file mode 100644
index 000000000000..53d60f8f24ae
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/tiptap/TiptapMarkdown.java
@@ -0,0 +1,1071 @@
+package com.dotcms.tiptap;
+
+import com.dotmarketing.util.Logger;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.commonmark.ext.gfm.strikethrough.Strikethrough;
+import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;
+import org.commonmark.ext.gfm.tables.TableBlock;
+import org.commonmark.ext.gfm.tables.TableCell;
+import org.commonmark.ext.gfm.tables.TableHead;
+import org.commonmark.ext.gfm.tables.TableRow;
+import org.commonmark.ext.gfm.tables.TablesExtension;
+import org.commonmark.node.AbstractVisitor;
+import org.commonmark.node.BlockQuote;
+import org.commonmark.node.BulletList;
+import org.commonmark.node.Code;
+import org.commonmark.node.Document;
+import org.commonmark.node.Emphasis;
+import org.commonmark.node.FencedCodeBlock;
+import org.commonmark.node.HardLineBreak;
+import org.commonmark.node.Heading;
+import org.commonmark.node.HtmlBlock;
+import org.commonmark.node.HtmlInline;
+import org.commonmark.node.Image;
+import org.commonmark.node.IndentedCodeBlock;
+import org.commonmark.node.Link;
+import org.commonmark.node.ListItem;
+import org.commonmark.node.Node;
+import org.commonmark.node.OrderedList;
+import org.commonmark.node.Paragraph;
+import org.commonmark.node.SoftLineBreak;
+import org.commonmark.node.StrongEmphasis;
+import org.commonmark.node.Text;
+import org.commonmark.node.ThematicBreak;
+import org.commonmark.parser.Parser;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Bidirectional converter between Tiptap JSON (ProseMirror document model) and Markdown.
+ * Functionally analogous to the @tiptap/markdown package
+ * (https://github.com/ueberdosis/tiptap/tree/main/packages/markdown).
+ *
+ * Supported nodes: doc, paragraph, heading, blockquote, bulletList, orderedList, listItem,
+ * codeBlock, horizontalRule, hardBreak, image, table, tableRow, tableHeader, tableCell.
+ * Supported marks: bold, italic, strike, code, link.
+ *
+ * Markdown parsing uses commonmark-java with GFM tables and strikethrough extensions.
+ */
+public final class TiptapMarkdown {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static final List EXTENSIONS = Arrays.asList(
+ TablesExtension.create(),
+ StrikethroughExtension.create()
+ );
+
+ private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build();
+
+ private TiptapMarkdown() { }
+
+ // ---------------------------------------------------------------------
+ // Public API
+ // ---------------------------------------------------------------------
+
+ /** Parse markdown into a Tiptap JSON document node. */
+ public static ObjectNode toTiptap(final String markdown) {
+ final Node root = PARSER.parse(markdown == null ? "" : markdown);
+ final TiptapBuilder builder = new TiptapBuilder();
+ root.accept(builder);
+ return builder.document();
+ }
+
+ /**
+ * Serialize a Tiptap JSON document (or any fragment) to markdown.
+ * Tiptap is extensible — projects routinely add custom node and mark
+ * types beyond the schema this converter recognizes. Anything unknown is
+ * logged once and skipped (its children are still rendered when present),
+ * so the converter is safe to run against arbitrary editor content.
+ */
+ public static String toMarkdown(final JsonNode tiptap) {
+ if (tiptap == null || tiptap.isNull()) {
+ return "";
+ }
+ final MarkdownWriter w = new MarkdownWriter();
+ w.renderNode(tiptap, null);
+ return w.finish();
+ }
+
+ /** Convenience overload that parses a JSON string first. */
+ public static String toMarkdown(final String tiptapJson) {
+ try {
+ return toMarkdown(MAPPER.readTree(tiptapJson));
+ } catch (final java.io.IOException e) {
+ throw new IllegalArgumentException("Invalid Tiptap JSON: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Convenience overload that accepts a dotCMS {@link com.dotmarketing.util.json.JSONObject}.
+ * The object's {@code toString()} is parsed into a Jackson {@link JsonNode} and forwarded
+ * to {@link #toMarkdown(JsonNode)}.
+ */
+ public static String toMarkdown(final com.dotmarketing.util.json.JSONObject tiptap) {
+ if (tiptap == null) return "";
+ return toMarkdown(tiptap.toString());
+ }
+
+ // =====================================================================
+ // Markdown -> Tiptap JSON (commonmark Visitor)
+ // =====================================================================
+
+ private static final class TiptapBuilder extends AbstractVisitor {
+
+ private final ObjectNode doc = MAPPER.createObjectNode();
+ private final ArrayNode docContent = doc.putArray("content");
+ private final Deque contentStack = new ArrayDeque<>();
+ /** Active marks while we walk inline children. */
+ private final Deque markStack = new ArrayDeque<>();
+
+ TiptapBuilder() {
+ doc.put("type", "doc");
+ contentStack.push(docContent);
+ }
+
+ ObjectNode document() {
+ return doc;
+ }
+
+ // ---- block nodes ------------------------------------------------
+
+ @Override
+ public void visit(final Document node) {
+ visitChildren(node);
+ }
+
+ @Override
+ public void visit(final Heading node) {
+ final ObjectNode n = newNode("heading");
+ n.putObject("attrs").put("level", node.getLevel());
+ pushChildrenInto(n);
+ visitChildren(node);
+ contentStack.pop();
+ }
+
+ @Override
+ public void visit(final Paragraph node) {
+ // Tiptap renders bare image-only paragraphs as an image block in some schemas, but
+ // ProseMirror's default schema wraps them in a paragraph too. We follow ProseMirror.
+ final ObjectNode n = newNode("paragraph");
+ pushChildrenInto(n);
+ visitChildren(node);
+ contentStack.pop();
+ }
+
+ @Override
+ public void visit(final BlockQuote node) {
+ final ObjectNode n = newNode("blockquote");
+ pushChildrenInto(n);
+ visitChildren(node);
+ contentStack.pop();
+ }
+
+ @Override
+ public void visit(final BulletList node) {
+ final ObjectNode n = newNode("bulletList");
+ pushChildrenInto(n);
+ visitChildren(node);
+ contentStack.pop();
+ }
+
+ @Override
+ public void visit(final OrderedList node) {
+ final ObjectNode n = newNode("orderedList");
+ final ObjectNode attrs = n.putObject("attrs");
+ attrs.put("start", node.getStartNumber());
+ pushChildrenInto(n);
+ visitChildren(node);
+ contentStack.pop();
+ }
+
+ @Override
+ public void visit(final ListItem node) {
+ final ObjectNode n = newNode("listItem");
+ pushChildrenInto(n);
+ visitChildren(node);
+ contentStack.pop();
+ }
+
+ @Override
+ public void visit(final FencedCodeBlock node) {
+ final ObjectNode n = newNode("codeBlock");
+ final String info = node.getInfo();
+ if (info != null && !info.isEmpty()) {
+ n.putObject("attrs").put("language", info);
+ }
+ final ArrayNode arr = n.putArray("content");
+ final String text = stripTrailingNewline(node.getLiteral());
+ if (!text.isEmpty()) {
+ arr.add(textNode(text));
+ }
+ }
+
+ @Override
+ public void visit(final IndentedCodeBlock node) {
+ final ObjectNode n = newNode("codeBlock");
+ final ArrayNode arr = n.putArray("content");
+ final String text = stripTrailingNewline(node.getLiteral());
+ if (!text.isEmpty()) {
+ arr.add(textNode(text));
+ }
+ }
+
+ @Override
+ public void visit(final ThematicBreak node) {
+ newNode("horizontalRule");
+ }
+
+ @Override
+ public void visit(final HtmlBlock node) {
+ // Preserve raw HTML inside a paragraph; Tiptap can render it via raw nodes.
+ final ObjectNode p = newNode("paragraph");
+ p.putArray("content").add(textNode(node.getLiteral()));
+ }
+
+ // ---- inline nodes -----------------------------------------------
+
+ @Override
+ public void visit(final Text node) {
+ // Commonmark occasionally produces empty Text tokens (e.g. after consuming a link
+ // closer); these add no content and only cause spurious round-trip differences.
+ final String literal = node.getLiteral();
+ if (literal == null || literal.isEmpty()) return;
+ currentContent().add(textNode(literal));
+ }
+
+ @Override
+ public void visit(final SoftLineBreak node) {
+ // ProseMirror represents soft breaks as a space inside inline content.
+ currentContent().add(textNode(" "));
+ }
+
+ @Override
+ public void visit(final HardLineBreak node) {
+ final ObjectNode br = MAPPER.createObjectNode();
+ br.put("type", "hardBreak");
+ currentContent().add(br);
+ }
+
+ @Override
+ public void visit(final Emphasis node) {
+ pushMark("italic", null);
+ visitChildren(node);
+ markStack.pop();
+ }
+
+ @Override
+ public void visit(final StrongEmphasis node) {
+ pushMark("bold", null);
+ visitChildren(node);
+ markStack.pop();
+ }
+
+ @Override
+ public void visit(final Code node) {
+ // Inline code: emit a text node with a `code` mark — content not visited further.
+ final ObjectNode txt = textNode(node.getLiteral());
+ final ArrayNode marks = txt.putArray("marks");
+ for (final ObjectNode m : markStack) {
+ marks.add(m.deepCopy());
+ }
+ final ObjectNode codeMark = MAPPER.createObjectNode();
+ codeMark.put("type", "code");
+ marks.add(codeMark);
+ currentContent().add(txt);
+ }
+
+ @Override
+ public void visit(final Link node) {
+ final ObjectNode attrs = MAPPER.createObjectNode();
+ attrs.put("href", emptyToNull(node.getDestination()));
+ if (node.getTitle() != null && !node.getTitle().isEmpty()) {
+ attrs.put("title", node.getTitle());
+ }
+ pushMark("link", attrs);
+ visitChildren(node);
+ markStack.pop();
+ }
+
+ @Override
+ public void visit(final Image node) {
+ final ObjectNode img = MAPPER.createObjectNode();
+ img.put("type", "image");
+ final ObjectNode attrs = img.putObject("attrs");
+ attrs.put("src", emptyToNull(node.getDestination()));
+ // Alt text is the textual content of the Image node's children.
+ final String alt = collectText(node);
+ if (!alt.isEmpty()) {
+ attrs.put("alt", alt);
+ }
+ if (node.getTitle() != null && !node.getTitle().isEmpty()) {
+ attrs.put("title", node.getTitle());
+ }
+ currentContent().add(img);
+ }
+
+ // ---- extensions: strikethrough, tables --------------------------
+
+ @Override
+ public void visit(final org.commonmark.node.CustomNode node) {
+ if (node instanceof Strikethrough) {
+ pushMark("strike", null);
+ visitChildren(node);
+ markStack.pop();
+ return;
+ }
+ super.visit(node);
+ }
+
+ @Override
+ public void visit(final org.commonmark.node.CustomBlock node) {
+ if (node instanceof TableBlock) {
+ emitTable((TableBlock) node);
+ return;
+ }
+ super.visit(node);
+ }
+
+ private void emitTable(final TableBlock table) {
+ final ObjectNode tableNode = newNode("table");
+ pushChildrenInto(tableNode);
+ for (Node section = table.getFirstChild(); section != null; section = section.getNext()) {
+ final boolean isHead = section instanceof TableHead;
+ for (Node row = section.getFirstChild(); row != null; row = row.getNext()) {
+ if (row instanceof TableRow) {
+ emitRow((TableRow) row, isHead);
+ }
+ }
+ }
+ contentStack.pop();
+ }
+
+ private void emitRow(final TableRow row, final boolean headerRow) {
+ final ObjectNode rowNode = newNode("tableRow");
+ pushChildrenInto(rowNode);
+ for (Node c = row.getFirstChild(); c != null; c = c.getNext()) {
+ if (c instanceof TableCell) {
+ emitCell((TableCell) c, headerRow);
+ }
+ }
+ contentStack.pop();
+ }
+
+ private void emitCell(final TableCell cell, final boolean headerRow) {
+ final boolean asHeader = headerRow || cell.isHeader();
+ final ObjectNode cellNode = newNode(asHeader ? "tableHeader" : "tableCell");
+ final ObjectNode attrs = cellNode.putObject("attrs");
+ attrs.put("colspan", 1);
+ attrs.put("rowspan", 1);
+ attrs.putNull("colwidth");
+ // Wrap the cell's inline content in a paragraph as ProseMirror table schema requires.
+ final ArrayNode cellContent = cellNode.putArray("content");
+ final ObjectNode para = MAPPER.createObjectNode();
+ para.put("type", "paragraph");
+ final ArrayNode paraContent = para.putArray("content");
+ cellContent.add(para);
+
+ contentStack.push(paraContent);
+ visitChildren(cell);
+ contentStack.pop();
+
+ if (paraContent.size() == 0) {
+ para.remove("content");
+ }
+ }
+
+ // ---- helpers ----------------------------------------------------
+
+ private ObjectNode newNode(final String type) {
+ final ObjectNode n = MAPPER.createObjectNode();
+ n.put("type", type);
+ currentContent().add(n);
+ return n;
+ }
+
+ private ArrayNode currentContent() {
+ return contentStack.peek();
+ }
+
+ private void pushChildrenInto(final ObjectNode parent) {
+ contentStack.push(parent.putArray("content"));
+ }
+
+ private void pushMark(final String type, final ObjectNode attrs) {
+ final ObjectNode mark = MAPPER.createObjectNode();
+ mark.put("type", type);
+ if (attrs != null && attrs.size() > 0) {
+ mark.set("attrs", attrs);
+ }
+ markStack.push(mark);
+ }
+
+ private ObjectNode textNode(final String text) {
+ final ObjectNode n = MAPPER.createObjectNode();
+ n.put("type", "text");
+ n.put("text", text);
+ if (!markStack.isEmpty()) {
+ final ArrayNode marks = n.putArray("marks");
+ // markStack iterates head (top) first; outer marks were pushed first (at bottom).
+ // Order doesn't affect rendering semantics but we emit outer-first for readability.
+ final ObjectNode[] arr = markStack.toArray(new ObjectNode[0]);
+ for (int i = arr.length - 1; i >= 0; i--) {
+ marks.add(arr[i].deepCopy());
+ }
+ }
+ return n;
+ }
+
+ private static String collectText(final Node n) {
+ final StringBuilder sb = new StringBuilder();
+ n.accept(new AbstractVisitor() {
+ @Override public void visit(final Text t) { sb.append(t.getLiteral()); }
+ @Override public void visit(final Code c) { sb.append(c.getLiteral()); }
+ });
+ return sb.toString();
+ }
+
+ private static String emptyToNull(final String s) {
+ return (s == null || s.isEmpty()) ? null : s;
+ }
+
+ private static String stripTrailingNewline(final String s) {
+ if (s == null) return "";
+ int end = s.length();
+ while (end > 0 && (s.charAt(end - 1) == '\n' || s.charAt(end - 1) == '\r')) end--;
+ return s.substring(0, end);
+ }
+ }
+
+ // =====================================================================
+ // Tiptap JSON -> Markdown
+ // =====================================================================
+
+ private static final class MarkdownWriter {
+
+ private final StringBuilder out = new StringBuilder();
+ private int listDepth = 0;
+ /** Stack of list contexts: each entry is {kind: "bullet"|"ordered", index, start}. */
+ private final Deque listStack = new ArrayDeque<>();
+ private final Deque blockPrefix = new ArrayDeque<>();
+ /** Unknown node/mark types we've already logged this conversion — log once each. */
+ private final Set loggedUnknown = new HashSet<>();
+
+ private void noteUnknown(final String kind, final String type) {
+ if (type == null || type.isEmpty()) return;
+ final String key = kind + ":" + type;
+ if (loggedUnknown.add(key)) {
+ Logger.info(TiptapMarkdown.class,
+ "TiptapMarkdown: skipping unsupported " + kind + " type '" + type + "'");
+ }
+ }
+
+ String finish() {
+ // collapse trailing blank lines down to a single newline
+ while (out.length() > 0 && out.charAt(out.length() - 1) == '\n') {
+ out.deleteCharAt(out.length() - 1);
+ }
+ return out.toString();
+ }
+
+ // ----- block dispatch -------------------------------------------
+
+ void renderNode(final JsonNode node, final JsonNode parent) {
+ if (node == null) return;
+ final String type = node.path("type").asText("");
+ switch (type) {
+ case "doc":
+ renderBlockChildren(node);
+ break;
+ case "paragraph":
+ emitBlock(renderInline(node.path("content")));
+ break;
+ case "heading":
+ final int level = Math.max(1, Math.min(6, node.path("attrs").path("level").asInt(1)));
+ emitBlock(repeat('#', level) + " " + renderInline(node.path("content")));
+ break;
+ case "blockquote":
+ renderBlockquote(node);
+ break;
+ case "bulletList":
+ renderList(node, false);
+ break;
+ case "orderedList":
+ renderList(node, true);
+ break;
+ case "listItem":
+ // Shouldn't be hit directly; handled inside renderList.
+ renderBlockChildren(node);
+ break;
+ case "codeBlock":
+ renderCodeBlock(node);
+ break;
+ case "horizontalRule":
+ emitBlock("---");
+ break;
+ case "hardBreak":
+ out.append(" \n");
+ break;
+ case "image":
+ case "dotImage":
+ emitBlock(renderImage(node));
+ break;
+ case "youtube": {
+ final String src = node.path("attrs").path("src").asText("");
+ if (!src.isEmpty()) {
+ emitBlock("[" + src + "](" + src + ")");
+ }
+ break;
+ }
+ case "table":
+ renderTable(node);
+ break;
+ case "text":
+ out.append(escapeText(node.path("text").asText(""), false));
+ break;
+ default:
+ // Unknown node — log once and render children if present, else drop.
+ noteUnknown("node", type);
+ if (node.has("content")) {
+ renderBlockChildren(node);
+ }
+ }
+ }
+
+ private void renderBlockChildren(final JsonNode node) {
+ final JsonNode content = node.path("content");
+ if (!content.isArray()) return;
+ for (final JsonNode child : content) {
+ renderNode(child, node);
+ }
+ }
+
+ private void emitBlock(final String text) {
+ // Ensure block separation: blank line between blocks at top level / inside blockquotes,
+ // newline only inside list items where the caller manages indentation.
+ applyPrefix(text);
+ ensureBlankLine();
+ }
+
+ private void applyPrefix(final String text) {
+ if (blockPrefix.isEmpty()) {
+ out.append(text);
+ return;
+ }
+ // Combined prefix is the concatenation of the stack (outer-first).
+ final StringBuilder pfx = new StringBuilder();
+ // Iterate from bottom (outermost) to top (innermost).
+ final String[] frames = blockPrefix.toArray(new String[0]);
+ for (int i = frames.length - 1; i >= 0; i--) {
+ pfx.append(frames[i]);
+ }
+ final String[] lines = text.split("\n", -1);
+ for (int i = 0; i < lines.length; i++) {
+ if (i > 0) out.append('\n');
+ out.append(pfx).append(lines[i]);
+ }
+ }
+
+ private void ensureBlankLine() {
+ if (out.length() == 0) return;
+ // collapse to exactly one blank line between blocks
+ if (out.charAt(out.length() - 1) != '\n') out.append('\n');
+ // Inside a list item we want a single \n between successive blocks, not a blank line.
+ // The caller (renderList) controls blank-line semantics for top-level / blockquote.
+ out.append('\n');
+ }
+
+ // ----- specific block renderers ---------------------------------
+
+ private void renderBlockquote(final JsonNode node) {
+ blockPrefix.push("> ");
+ try {
+ final JsonNode content = node.path("content");
+ if (content.isArray()) {
+ final Iterator it = content.elements();
+ while (it.hasNext()) {
+ renderNode(it.next(), node);
+ }
+ }
+ } finally {
+ blockPrefix.pop();
+ }
+ }
+
+ private void renderList(final JsonNode node, final boolean ordered) {
+ final JsonNode items = node.path("content");
+ if (!items.isArray() || items.size() == 0) return;
+ final int start = ordered ? node.path("attrs").path("start").asInt(1) : 1;
+ listDepth++;
+ int idx = 0;
+ for (final JsonNode item : items) {
+ final String marker = ordered ? ((start + idx) + ". ") : "- ";
+ renderListItem(item, marker);
+ idx++;
+ }
+ listDepth--;
+ // List acts as a block — ensure trailing blank line at top level.
+ if (listDepth == 0) ensureBlankLine();
+ }
+
+ private void renderListItem(final JsonNode item, final String marker) {
+ // Render each child block; first child gets the marker, others get hanging indent.
+ final JsonNode content = item.path("content");
+ if (!content.isArray() || content.size() == 0) {
+ applyPrefix(marker);
+ out.append('\n');
+ return;
+ }
+ final String indent = repeatStr(" ", marker.length());
+
+ // Capture each child block into a String so we can prefix it.
+ int i = 0;
+ for (final JsonNode child : content) {
+ final String rendered = captureChild(child, item);
+ final String[] lines = rendered.split("\n", -1);
+ for (int li = 0; li < lines.length; li++) {
+ final String prefix = (i == 0 && li == 0) ? marker : indent;
+ if (li > 0) out.append('\n');
+ // Suppress completely-blank trailing line that would otherwise add a stray indent.
+ if (li == lines.length - 1 && lines[li].isEmpty()) {
+ // Don't emit a prefix-only blank line.
+ continue;
+ }
+ applyPrefix(prefix + lines[li]);
+ }
+ out.append('\n');
+ if (i < content.size() - 1) out.append('\n'); // blank line between blocks in a list item
+ i++;
+ }
+ }
+
+ /** Render a child block into a string without touching the main buffer (no surrounding blank lines). */
+ private String captureChild(final JsonNode child, final JsonNode parent) {
+ final StringBuilder saved = new StringBuilder(out);
+ out.setLength(0);
+ // Temporarily clear blockPrefix because the caller will prefix.
+ final Deque savedPfx = new ArrayDeque<>(blockPrefix);
+ blockPrefix.clear();
+ try {
+ renderNode(child, parent);
+ // Trim trailing newlines from the captured block.
+ while (out.length() > 0 && out.charAt(out.length() - 1) == '\n') {
+ out.deleteCharAt(out.length() - 1);
+ }
+ return out.toString();
+ } finally {
+ out.setLength(0);
+ out.append(saved);
+ blockPrefix.clear();
+ for (final Iterator it = savedPfx.descendingIterator(); it.hasNext(); ) {
+ blockPrefix.push(it.next());
+ }
+ }
+ }
+
+ private void renderCodeBlock(final JsonNode node) {
+ final String lang = node.path("attrs").path("language").asText("");
+ final StringBuilder body = new StringBuilder();
+ for (final JsonNode child : node.path("content")) {
+ if ("text".equals(child.path("type").asText(""))) {
+ body.append(child.path("text").asText(""));
+ }
+ }
+ final String fence = pickFence(body.toString());
+ final StringBuilder sb = new StringBuilder();
+ sb.append(fence);
+ if (!lang.isEmpty()) sb.append(lang);
+ sb.append('\n').append(body);
+ if (body.length() == 0 || body.charAt(body.length() - 1) != '\n') sb.append('\n');
+ sb.append(fence);
+ emitBlock(sb.toString());
+ }
+
+ private String pickFence(final String body) {
+ // Use a longer fence if the body itself contains triple backticks.
+ int max = 2;
+ int run = 0;
+ for (int i = 0; i < body.length(); i++) {
+ if (body.charAt(i) == '`') {
+ run++;
+ if (run > max) max = run;
+ } else {
+ run = 0;
+ }
+ }
+ return repeat('`', Math.max(3, max + 1));
+ }
+
+ private String renderImage(final JsonNode node) {
+ final JsonNode attrs = node.path("attrs");
+ final String alt = attrs.path("alt").asText("");
+ final String src = attrs.path("src").asText("");
+ final String title = attrs.path("title").asText("");
+ final StringBuilder sb = new StringBuilder();
+ sb.append(".append(src);
+ if (!title.isEmpty()) sb.append(" \"").append(title.replace("\"", "\\\"")).append('"');
+ sb.append(')');
+ return sb.toString();
+ }
+
+ private void renderTable(final JsonNode node) {
+ // Tiptap table: rows of tableHeader/tableCell. First row is the header in GFM tables.
+ final JsonNode rows = node.path("content");
+ if (!rows.isArray() || rows.size() == 0) return;
+
+ final List> cells = new ArrayList<>();
+ int maxCols = 0;
+ for (final JsonNode row : rows) {
+ final List rowCells = new ArrayList<>();
+ for (final JsonNode cell : row.path("content")) {
+ rowCells.add(renderCellInline(cell));
+ }
+ cells.add(rowCells);
+ if (rowCells.size() > maxCols) maxCols = rowCells.size();
+ }
+
+ // Compute column widths for alignment (purely cosmetic).
+ final int[] widths = new int[maxCols];
+ for (final List r : cells) {
+ for (int c = 0; c < r.size(); c++) {
+ widths[c] = Math.max(widths[c], r.get(c).length());
+ }
+ }
+ for (int c = 0; c < widths.length; c++) widths[c] = Math.max(3, widths[c]);
+
+ final StringBuilder sb = new StringBuilder();
+ // header row
+ sb.append(buildRow(cells.get(0), widths)).append('\n');
+ // separator
+ sb.append('|');
+ for (int c = 0; c < maxCols; c++) sb.append(' ').append(repeat('-', widths[c])).append(" |");
+ sb.append('\n');
+ // body rows
+ for (int r = 1; r < cells.size(); r++) {
+ sb.append(buildRow(cells.get(r), widths));
+ if (r < cells.size() - 1) sb.append('\n');
+ }
+ emitBlock(sb.toString());
+ }
+
+ private String buildRow(final List rowCells, final int[] widths) {
+ final StringBuilder sb = new StringBuilder("|");
+ for (int c = 0; c < widths.length; c++) {
+ final String v = c < rowCells.size() ? rowCells.get(c) : "";
+ sb.append(' ').append(padRight(v, widths[c])).append(" |");
+ }
+ return sb.toString();
+ }
+
+ private String renderCellInline(final JsonNode cell) {
+ // Cell contains paragraph(s) of inline content; render and replace newlines with
.
+ final StringBuilder sb = new StringBuilder();
+ for (final JsonNode block : cell.path("content")) {
+ if ("paragraph".equals(block.path("type").asText(""))) {
+ if (sb.length() > 0) sb.append("
");
+ sb.append(renderInline(block.path("content")));
+ }
+ }
+ // Pipes are already escaped by escapeText() during inline rendering.
+ return sb.toString();
+ }
+
+ // ----- inline rendering with mark tracking ----------------------
+
+ private String renderInline(final JsonNode nodes) {
+ if (nodes == null || !nodes.isArray()) return "";
+ final StringBuilder sb = new StringBuilder();
+ final List active = new ArrayList<>();
+ final Map activeAttrs = new LinkedHashMap<>();
+
+ for (int i = 0; i < nodes.size(); i++) {
+ final JsonNode n = nodes.get(i);
+ final String type = n.path("type").asText("");
+ if ("text".equals(type)) {
+ final List wanted = new ArrayList<>();
+ final Map wantedAttrs = new LinkedHashMap<>();
+ final JsonNode marks = n.path("marks");
+ if (marks.isArray()) {
+ // Render order: link (outermost) > bold > italic > strike > code (innermost).
+ // Marks that don't render in markdown (e.g. underline) are skipped entirely
+ // so they don't disturb the open/close bookkeeping. Log unknown marks once.
+ final List sorted = new ArrayList<>();
+ marks.forEach(m -> {
+ final String mt = m.path("type").asText("");
+ if (rendersInMarkdown(mt)) {
+ sorted.add(m);
+ } else if (!isSilentlyDroppedMark(mt)) {
+ noteUnknown("mark", mt);
+ }
+ });
+ sorted.sort((a, b) -> rank(a.path("type").asText("")) - rank(b.path("type").asText("")));
+ for (final JsonNode m : sorted) {
+ final String mt = m.path("type").asText("");
+ wanted.add(mt);
+ wantedAttrs.put(mt, m.has("attrs") && m.get("attrs").isObject()
+ ? (ObjectNode) m.get("attrs") : null);
+ }
+ }
+ // Close marks that aren't wanted, in LIFO order — but first lift any trailing
+ // whitespace out of the marked span. Markdown emphasis cannot close after a
+ // space (`*x *` is not valid italic-close), so leaving it inline would cause
+ // the reader to scan past for a real closer and apply emphasis twice.
+ final String trailingWs = extractTrailingWhitespace(sb);
+ closeMarksDownTo(sb, active, activeAttrs, wanted, wantedAttrs);
+ if (!trailingWs.isEmpty()) sb.append(trailingWs);
+
+ // For opening: leading whitespace in the incoming text must be emitted
+ // BEFORE the opening delimiter for the same reason (`* x*` won't open).
+ // Code escaping is determined by whether the OUTGOING text will be inside
+ // code marks (either still-active or about-to-open), not by current state.
+ final boolean willBeInCode = wanted.contains("code");
+ String incoming = escapeText(n.path("text").asText(""), willBeInCode);
+ final String leadingWs = leadingWhitespace(incoming);
+ if (!leadingWs.isEmpty()) {
+ sb.append(leadingWs);
+ incoming = incoming.substring(leadingWs.length());
+ }
+ openMarksUpTo(sb, active, activeAttrs, wanted, wantedAttrs);
+ sb.append(incoming);
+ } else if ("hardBreak".equals(type)) {
+ final String tws = extractTrailingWhitespace(sb);
+ closeAllMarks(sb, active, activeAttrs);
+ if (!tws.isEmpty()) sb.append(tws);
+ sb.append(" \n");
+ } else if ("image".equals(type) || "dotImage".equals(type)) {
+ final String tws = extractTrailingWhitespace(sb);
+ closeAllMarks(sb, active, activeAttrs);
+ if (!tws.isEmpty()) sb.append(tws);
+ sb.append(renderImage(n));
+ } else if ("youtube".equals(type)) {
+ // No native markdown for YouTube — emit a plain link to the video.
+ final String tws = extractTrailingWhitespace(sb);
+ closeAllMarks(sb, active, activeAttrs);
+ if (!tws.isEmpty()) sb.append(tws);
+ final String src = n.path("attrs").path("src").asText("");
+ if (!src.isEmpty()) sb.append('[').append(src).append("](").append(src).append(')');
+ } else {
+ // Unknown inline node — log once and drop, after closing any active marks.
+ noteUnknown("inline node", type);
+ final String tws = extractTrailingWhitespace(sb);
+ closeAllMarks(sb, active, activeAttrs);
+ if (!tws.isEmpty()) sb.append(tws);
+ }
+ }
+ final String finalTws = extractTrailingWhitespace(sb);
+ closeAllMarks(sb, active, activeAttrs);
+ if (!finalTws.isEmpty()) sb.append(finalTws);
+ return sb.toString();
+ }
+
+ /** Only marks with real markdown syntax participate in open/close tracking. */
+ private static boolean rendersInMarkdown(final String type) {
+ switch (type) {
+ case "bold": case "italic": case "strike": case "code": case "link":
+ return true;
+ default:
+ return false; // underline, highlight, etc. have no markdown — drop silently
+ }
+ }
+
+ /**
+ * Known marks with no markdown representation that we deliberately drop
+ * silently — distinct from arbitrary user-defined marks, which should be
+ * logged so operators see them.
+ */
+ private static boolean isSilentlyDroppedMark(final String type) {
+ switch (type) {
+ case "underline": case "highlight": case "subscript": case "superscript":
+ case "textStyle": case "color":
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /** Pop and return any trailing whitespace currently in the buffer (may be empty). */
+ private static String extractTrailingWhitespace(final StringBuilder sb) {
+ int end = sb.length();
+ while (end > 0) {
+ final char c = sb.charAt(end - 1);
+ if (c == ' ' || c == '\t') end--;
+ else break;
+ }
+ final String ws = sb.substring(end);
+ if (!ws.isEmpty()) sb.setLength(end);
+ return ws;
+ }
+
+ private static String leadingWhitespace(final String s) {
+ int i = 0;
+ while (i < s.length()) {
+ final char c = s.charAt(i);
+ if (c == ' ' || c == '\t') i++;
+ else break;
+ }
+ return s.substring(0, i);
+ }
+
+ private static int rank(final String mark) {
+ switch (mark) {
+ case "link": return 0;
+ case "bold": return 1;
+ case "italic": return 2;
+ case "strike": return 3;
+ case "code": return 4;
+ default: return 5;
+ }
+ }
+
+ private void closeMarksDownTo(final StringBuilder sb,
+ final List active, final Map activeAttrs,
+ final List wanted, final Map wantedAttrs) {
+ // Walk the active stack from the innermost outward and close anything that
+ // either isn't in `wanted` or whose attrs changed (e.g. different link href).
+ while (!active.isEmpty()) {
+ final String top = active.get(active.size() - 1);
+ final boolean stillWanted = wanted.contains(top)
+ && sameAttrs(activeAttrs.get(top), wantedAttrs.get(top));
+ if (!stillWanted) {
+ sb.append(closeMark(top, activeAttrs.get(top)));
+ active.remove(active.size() - 1);
+ activeAttrs.remove(top);
+ } else {
+ // If the top is wanted but a mark *below* it differs, we must still close
+ // the top to get to the inner mismatch. Detect that by checking the rest.
+ final List below = active.subList(0, active.size() - 1);
+ boolean innerMismatch = false;
+ for (final String m : below) {
+ if (!wanted.contains(m) || !sameAttrs(activeAttrs.get(m), wantedAttrs.get(m))) {
+ innerMismatch = true; break;
+ }
+ }
+ if (innerMismatch) {
+ sb.append(closeMark(top, activeAttrs.get(top)));
+ active.remove(active.size() - 1);
+ activeAttrs.remove(top);
+ } else {
+ break;
+ }
+ }
+ }
+ }
+
+ private void openMarksUpTo(final StringBuilder sb,
+ final List active, final Map activeAttrs,
+ final List wanted, final Map wantedAttrs) {
+ for (final String m : wanted) {
+ if (active.contains(m)) continue;
+ sb.append(openMark(m, wantedAttrs.get(m)));
+ active.add(m);
+ activeAttrs.put(m, wantedAttrs.get(m));
+ }
+ }
+
+ private void closeAllMarks(final StringBuilder sb, final List active,
+ final Map activeAttrs) {
+ for (int i = active.size() - 1; i >= 0; i--) {
+ sb.append(closeMark(active.get(i), activeAttrs.get(active.get(i))));
+ }
+ active.clear();
+ activeAttrs.clear();
+ }
+
+ private static String openMark(final String type, final ObjectNode attrs) {
+ switch (type) {
+ case "bold": return "**";
+ case "italic": return "*";
+ case "strike": return "~~";
+ case "code": return "`";
+ case "link": return "[";
+ default: return "";
+ }
+ }
+
+ private static String closeMark(final String type, final ObjectNode attrs) {
+ switch (type) {
+ case "bold": return "**";
+ case "italic": return "*";
+ case "strike": return "~~";
+ case "code": return "`";
+ case "link":
+ final String href = attrs == null ? "" : attrs.path("href").asText("");
+ final String title = attrs == null ? "" : attrs.path("title").asText("");
+ final StringBuilder sb = new StringBuilder("](").append(href);
+ if (!title.isEmpty()) sb.append(" \"").append(title.replace("\"", "\\\"")).append('"');
+ sb.append(')');
+ return sb.toString();
+ default:
+ return "";
+ }
+ }
+
+ private static boolean sameAttrs(final ObjectNode a, final ObjectNode b) {
+ if (a == null && b == null) return true;
+ if (a == null || b == null) return false;
+ return a.equals(b);
+ }
+
+ // ----- escaping --------------------------------------------------
+
+ private static String escapeText(final String s, final boolean inCode) {
+ if (inCode) return s; // literal inside ` ... `
+ final StringBuilder sb = new StringBuilder(s.length());
+ for (int i = 0; i < s.length(); i++) {
+ final char c = s.charAt(i);
+ switch (c) {
+ case '\\': case '`': case '*': case '_':
+ case '{': case '}': case '[': case ']':
+ case '(': case ')': case '#': case '+':
+ case '-': case '!': case '|': case '<': case '>':
+ sb.append('\\').append(c); break;
+ default:
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ private static String escapeLinkText(final String s) {
+ return s.replace("[", "\\[").replace("]", "\\]");
+ }
+
+ // ----- string utils ---------------------------------------------
+
+ private static String repeat(final char c, final int n) {
+ final char[] a = new char[Math.max(0, n)];
+ Arrays.fill(a, c);
+ return new String(a);
+ }
+
+ private static String repeatStr(final String s, final int n) {
+ final StringBuilder sb = new StringBuilder(s.length() * Math.max(0, n));
+ for (int i = 0; i < n; i++) sb.append(s);
+ return sb.toString();
+ }
+
+ private static String padRight(final String s, final int width) {
+ if (s.length() >= width) return s;
+ return s + repeat(' ', width - s.length());
+ }
+
+ // ----- list context ---------------------------------------------
+
+ private static final class ListCtx {
+ final String kind; int idx; final int start;
+ ListCtx(final String kind, final int start) { this.kind = kind; this.start = start; this.idx = start; }
+ }
+ }
+}
diff --git a/dotCMS/src/test/java/com/dotcms/tiptap/TiptapMarkdownBlogContentTest.java b/dotCMS/src/test/java/com/dotcms/tiptap/TiptapMarkdownBlogContentTest.java
new file mode 100644
index 000000000000..2b0626a2db9e
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/tiptap/TiptapMarkdownBlogContentTest.java
@@ -0,0 +1,302 @@
+package com.dotcms.tiptap;
+
+import com.dotcms.UnitTestBase;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.io.InputStream;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * End-to-end tests for {@link TiptapMarkdown} using real dotCMS Blog content
+ * loaded from {@code blog-test.json}. Each contentlet's {@code body} field
+ * holds a Tiptap document; this exercises the converter against production-
+ * shaped input rather than synthetic fixtures.
+ */
+public class TiptapMarkdownBlogContentTest extends UnitTestBase {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static JsonNode contentlets;
+
+ /** All node types our converter supports as block or atomic content. */
+ private static final Set SUPPORTED_NODE_TYPES = new HashSet<>();
+ /** All mark types our converter supports. */
+ private static final Set SUPPORTED_MARK_TYPES = new HashSet<>();
+ static {
+ SUPPORTED_NODE_TYPES.add("doc");
+ SUPPORTED_NODE_TYPES.add("paragraph");
+ SUPPORTED_NODE_TYPES.add("heading");
+ SUPPORTED_NODE_TYPES.add("blockquote");
+ SUPPORTED_NODE_TYPES.add("bulletList");
+ SUPPORTED_NODE_TYPES.add("orderedList");
+ SUPPORTED_NODE_TYPES.add("listItem");
+ SUPPORTED_NODE_TYPES.add("codeBlock");
+ SUPPORTED_NODE_TYPES.add("horizontalRule");
+ SUPPORTED_NODE_TYPES.add("hardBreak");
+ SUPPORTED_NODE_TYPES.add("image");
+ SUPPORTED_NODE_TYPES.add("table");
+ SUPPORTED_NODE_TYPES.add("tableRow");
+ SUPPORTED_NODE_TYPES.add("tableHeader");
+ SUPPORTED_NODE_TYPES.add("tableCell");
+ SUPPORTED_NODE_TYPES.add("text");
+ // dotCMS-specific Tiptap extensions handled by the converter:
+ SUPPORTED_NODE_TYPES.add("dotImage"); // rendered as standard markdown image
+ SUPPORTED_NODE_TYPES.add("youtube"); // rendered as a plain markdown link to src
+
+ SUPPORTED_MARK_TYPES.add("bold");
+ SUPPORTED_MARK_TYPES.add("italic");
+ SUPPORTED_MARK_TYPES.add("strike");
+ SUPPORTED_MARK_TYPES.add("code");
+ SUPPORTED_MARK_TYPES.add("link");
+ // Marks that have no markdown syntax — dropped silently on conversion:
+ SUPPORTED_MARK_TYPES.add("underline");
+ }
+
+ @BeforeClass
+ public static void loadFixture() throws Exception {
+ try (InputStream in = TiptapMarkdownBlogContentTest.class
+ .getClassLoader().getResourceAsStream("blog-test.json")) {
+ assertNotNull("blog-test.json must be on the test classpath", in);
+ final JsonNode root = MAPPER.readTree(in);
+ contentlets = root.path("contentlets");
+ assertTrue("blog-test.json must contain a contentlets array",
+ contentlets.isArray() && contentlets.size() > 0);
+ }
+ }
+
+ /**
+ * Sanity check that the fixture has the structure we assume — a `contentlets`
+ * array of Blog objects whose `body` field is a Tiptap doc node.
+ */
+ @Test
+ public void fixture_has_blog_contentlets_with_tiptap_bodies() {
+ assertTrue("expected multiple blog contentlets", contentlets.size() >= 1);
+ for (final JsonNode c : contentlets) {
+ assertTrue("each contentlet must have a body", c.has("body"));
+ assertEquals("body must be a tiptap doc",
+ "doc", c.path("body").path("type").asText());
+ assertTrue("body must have content nodes",
+ c.path("body").path("content").isArray()
+ && c.path("body").path("content").size() > 0);
+ }
+ }
+
+ /**
+ * Verify every node and mark in the fixture is one our converter supports.
+ * Any unknown type would silently degrade, so flag it loudly here.
+ */
+ @Test
+ public void every_node_type_in_fixture_is_supported() {
+ final Set seenNodes = new HashSet<>();
+ final Set seenMarks = new HashSet<>();
+ for (final JsonNode c : contentlets) {
+ collectTypes(c.path("body"), seenNodes, seenMarks);
+ }
+
+ final Set unsupportedNodes = new HashSet<>(seenNodes);
+ unsupportedNodes.removeAll(SUPPORTED_NODE_TYPES);
+ final Set unsupportedMarks = new HashSet<>(seenMarks);
+ unsupportedMarks.removeAll(SUPPORTED_MARK_TYPES);
+
+ assertTrue("fixture contains unsupported node types: " + unsupportedNodes,
+ unsupportedNodes.isEmpty());
+ assertTrue("fixture contains unsupported mark types: " + unsupportedMarks,
+ unsupportedMarks.isEmpty());
+ }
+
+ /**
+ * Convert each blog body to markdown and assert the output is non-empty
+ * and contains structural markers proportional to what's in the body.
+ */
+ @Test
+ public void every_blog_body_renders_to_non_empty_markdown() {
+ for (final JsonNode c : contentlets) {
+ final String title = c.path("title").asText("");
+ final JsonNode body = c.path("body");
+ final String md;
+ try {
+ md = TiptapMarkdown.toMarkdown(body);
+ } catch (final Exception e) {
+ fail("toMarkdown threw for blog '" + title + "': " + e.getMessage());
+ return;
+ }
+ assertFalse("markdown for blog '" + title + "' should not be empty",
+ md.trim().isEmpty());
+
+ // If the body has any headings, expect a `#` somewhere in the output.
+ if (containsType(body, "heading")) {
+ assertTrue("blog '" + title + "' has headings — md should contain `#`: "
+ + md.substring(0, Math.min(200, md.length())),
+ md.contains("#"));
+ }
+ // If the body has any code blocks, expect a fenced block in the output.
+ if (containsType(body, "codeBlock")) {
+ assertTrue("blog '" + title + "' has codeBlocks — md should contain ```",
+ md.contains("```"));
+ }
+ // If the body has any tables, expect a `|` row separator.
+ if (containsType(body, "table")) {
+ assertTrue("blog '" + title + "' has tables — md should contain `|`",
+ md.contains("|") && md.contains("---"));
+ }
+ }
+ }
+
+ /**
+ * Re-parse each rendered markdown back to Tiptap JSON and verify we get a
+ * non-empty doc. This is the end-to-end bidirectional check on real data.
+ */
+ @Test
+ public void every_blog_body_round_trips_to_a_non_empty_doc() {
+ for (final JsonNode c : contentlets) {
+ final String title = c.path("title").asText("");
+ final JsonNode body = c.path("body");
+ final String md = TiptapMarkdown.toMarkdown(body);
+
+ final JsonNode reparsed;
+ try {
+ reparsed = TiptapMarkdown.toTiptap(md);
+ } catch (final Exception e) {
+ fail("toTiptap threw for blog '" + title + "': " + e.getMessage());
+ return;
+ }
+
+ assertEquals("re-parsed root must be a doc for blog '" + title + "'",
+ "doc", reparsed.path("type").asText());
+ assertTrue("re-parsed doc must have content for blog '" + title + "'",
+ reparsed.path("content").isArray()
+ && reparsed.path("content").size() > 0);
+ }
+ }
+
+ /**
+ * After one normalization pass (markdown → tiptap → markdown → tiptap),
+ * a second pass through the same pipeline must produce identical JSON —
+ * i.e. the converter reaches a fixed point. Run on every blog body.
+ */
+ @Test
+ public void every_blog_body_reaches_a_stable_fixed_point() {
+ for (final JsonNode c : contentlets) {
+ final String title = c.path("title").asText("");
+ final JsonNode body = c.path("body");
+
+ final String md1 = TiptapMarkdown.toMarkdown(body);
+ final JsonNode once = TiptapMarkdown.toTiptap(md1);
+ final String md2 = TiptapMarkdown.toMarkdown(once);
+ final JsonNode twice = TiptapMarkdown.toTiptap(md2);
+
+ assertEquals("conversion must be a fixed point for blog '" + title + "'",
+ once, twice);
+ }
+ }
+
+ /**
+ * Spot-check that distinctive text from each blog survives the conversion.
+ * Pull the first text node out of the body and assert it appears in the
+ * generated markdown (with markdown special chars stripped for the compare).
+ */
+ @Test
+ public void first_text_snippet_appears_in_rendered_markdown() {
+ for (final JsonNode c : contentlets) {
+ final String title = c.path("title").asText("");
+ final String firstText = findFirstText(c.path("body"));
+ if (firstText == null || firstText.length() < 12) {
+ // Some blogs may start with an image or table — skip those for this check.
+ continue;
+ }
+ final String snippet = firstText.substring(0, Math.min(40, firstText.length()));
+ final String md = TiptapMarkdown.toMarkdown(c.path("body"));
+
+ // Compare after stripping markdown escape backslashes so escaping doesn't
+ // cause a false negative on the substring search.
+ final String stripped = md.replace("\\", "");
+ assertTrue("blog '" + title + "': expected to find snippet '"
+ + snippet + "' in markdown",
+ stripped.contains(snippet));
+ }
+ }
+
+ /**
+ * Code mark content should appear literally in the output (no backslash
+ * escaping), since inline-code text is rendered as-is between backticks.
+ */
+ @Test
+ public void inline_code_content_appears_literally_in_markdown() {
+ // Find a blog with inline code (the providerConfig migration blog certainly has it).
+ for (final JsonNode c : contentlets) {
+ final String literal = findFirstTextWithMark(c.path("body"), "code");
+ if (literal == null || literal.isEmpty()) continue;
+
+ final String md = TiptapMarkdown.toMarkdown(c.path("body"));
+ assertTrue("inline-code text '" + literal + "' must appear literally in markdown",
+ md.contains("`" + literal + "`"));
+ return; // one positive case is enough — others are covered by other tests
+ }
+ // If no blog had inline code, the fixture changed — note it but don't fail.
+ }
+
+ // ------------------------------------------------------------------
+ // helpers
+ // ------------------------------------------------------------------
+
+ private static void collectTypes(final JsonNode n,
+ final Set nodes, final Set marks) {
+ if (n == null || n.isMissingNode() || n.isNull()) return;
+ final String t = n.path("type").asText("");
+ if (!t.isEmpty()) nodes.add(t);
+ for (final JsonNode m : n.path("marks")) {
+ final String mt = m.path("type").asText("");
+ if (!mt.isEmpty()) marks.add(mt);
+ }
+ for (final JsonNode c : n.path("content")) {
+ collectTypes(c, nodes, marks);
+ }
+ }
+
+ private static boolean containsType(final JsonNode n, final String type) {
+ if (n == null) return false;
+ if (type.equals(n.path("type").asText())) return true;
+ for (final JsonNode c : n.path("content")) {
+ if (containsType(c, type)) return true;
+ }
+ return false;
+ }
+
+ private static String findFirstText(final JsonNode n) {
+ if (n == null) return null;
+ if ("text".equals(n.path("type").asText())) {
+ final String s = n.path("text").asText("");
+ if (!s.isEmpty()) return s;
+ }
+ for (final JsonNode c : n.path("content")) {
+ final String hit = findFirstText(c);
+ if (hit != null) return hit;
+ }
+ return null;
+ }
+
+ private static String findFirstTextWithMark(final JsonNode n, final String markType) {
+ if (n == null) return null;
+ if ("text".equals(n.path("type").asText())) {
+ for (final JsonNode m : n.path("marks")) {
+ if (markType.equals(m.path("type").asText())) {
+ return n.path("text").asText("");
+ }
+ }
+ }
+ for (final JsonNode c : n.path("content")) {
+ final String hit = findFirstTextWithMark(c, markType);
+ if (hit != null) return hit;
+ }
+ return null;
+ }
+}
diff --git a/dotCMS/src/test/java/com/dotcms/tiptap/TiptapMarkdownTest.java b/dotCMS/src/test/java/com/dotcms/tiptap/TiptapMarkdownTest.java
new file mode 100644
index 000000000000..d5780979b9b8
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/tiptap/TiptapMarkdownTest.java
@@ -0,0 +1,514 @@
+package com.dotcms.tiptap;
+
+import com.dotcms.UnitTestBase;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Unit tests for {@link TiptapMarkdown}. Verifies Markdown → Tiptap JSON,
+ * Tiptap JSON → Markdown, and round-trip semantic stability for every
+ * node type and mark we claim to support.
+ */
+public class TiptapMarkdownTest extends UnitTestBase {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ // =====================================================================
+ // Markdown -> Tiptap JSON
+ // =====================================================================
+
+ @Test
+ public void parse_empty_input_yields_empty_doc() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("");
+ assertEquals("doc", doc.path("type").asText());
+ assertTrue(doc.path("content").isArray());
+ assertEquals(0, doc.path("content").size());
+ }
+
+ @Test
+ public void parse_null_input_does_not_throw() {
+ final JsonNode doc = TiptapMarkdown.toTiptap(null);
+ assertEquals("doc", doc.path("type").asText());
+ }
+
+ @Test
+ public void parse_paragraph_with_plain_text() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("hello world");
+ final JsonNode para = doc.path("content").get(0);
+ assertEquals("paragraph", para.path("type").asText());
+ final JsonNode text = para.path("content").get(0);
+ assertEquals("text", text.path("type").asText());
+ assertEquals("hello world", text.path("text").asText());
+ assertFalse("plain text should carry no marks", text.has("marks"));
+ }
+
+ @Test
+ public void parse_heading_levels_1_through_6() {
+ for (int level = 1; level <= 6; level++) {
+ final String md = repeat('#', level) + " title";
+ final JsonNode doc = TiptapMarkdown.toTiptap(md);
+ final JsonNode h = doc.path("content").get(0);
+ assertEquals("heading at level " + level, "heading", h.path("type").asText());
+ assertEquals(level, h.path("attrs").path("level").asInt());
+ assertEquals("title", h.path("content").get(0).path("text").asText());
+ }
+ }
+
+ @Test
+ public void parse_bold_italic_strike_marks() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("**b** *i* ~~s~~");
+ final JsonNode para = doc.path("content").get(0);
+ // Children: "b" with bold, " ", "i" with italic, " ", "s" with strike
+ assertHasMark(findTextWithValue(para, "b"), "bold");
+ assertHasMark(findTextWithValue(para, "i"), "italic");
+ assertHasMark(findTextWithValue(para, "s"), "strike");
+ }
+
+ @Test
+ public void parse_inline_code_mark() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("a `code` b");
+ final JsonNode codeText = findTextWithValue(doc, "code");
+ assertNotNull(codeText);
+ assertHasMark(codeText, "code");
+ }
+
+ @Test
+ public void parse_link_mark_with_title() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("[dotcms](https://dotcms.com \"home\")");
+ final JsonNode txt = findTextWithValue(doc, "dotcms");
+ assertNotNull(txt);
+ final JsonNode mark = firstMarkOfType(txt, "link");
+ assertNotNull(mark);
+ assertEquals("https://dotcms.com", mark.path("attrs").path("href").asText());
+ assertEquals("home", mark.path("attrs").path("title").asText());
+ }
+
+ @Test
+ public void parse_blockquote_wraps_paragraph() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("> quoted");
+ final JsonNode bq = doc.path("content").get(0);
+ assertEquals("blockquote", bq.path("type").asText());
+ assertEquals("paragraph", bq.path("content").get(0).path("type").asText());
+ assertEquals("quoted", bq.path("content").get(0).path("content").get(0).path("text").asText());
+ }
+
+ @Test
+ public void parse_bullet_list_with_items() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("- a\n- b\n- c");
+ final JsonNode list = doc.path("content").get(0);
+ assertEquals("bulletList", list.path("type").asText());
+ assertEquals(3, list.path("content").size());
+ assertEquals("listItem", list.path("content").get(0).path("type").asText());
+ }
+
+ @Test
+ public void parse_ordered_list_records_start_attr() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("3. three\n4. four");
+ final JsonNode list = doc.path("content").get(0);
+ assertEquals("orderedList", list.path("type").asText());
+ assertEquals(3, list.path("attrs").path("start").asInt());
+ assertEquals(2, list.path("content").size());
+ }
+
+ @Test
+ public void parse_fenced_code_block_with_language() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("```java\nint x = 1;\n```");
+ final JsonNode code = doc.path("content").get(0);
+ assertEquals("codeBlock", code.path("type").asText());
+ assertEquals("java", code.path("attrs").path("language").asText());
+ assertEquals("int x = 1;", code.path("content").get(0).path("text").asText());
+ }
+
+ @Test
+ public void parse_fenced_code_block_without_language() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("```\nplain\n```");
+ final JsonNode code = doc.path("content").get(0);
+ assertEquals("codeBlock", code.path("type").asText());
+ assertFalse("no language attr expected", code.has("attrs"));
+ assertEquals("plain", code.path("content").get(0).path("text").asText());
+ }
+
+ @Test
+ public void parse_horizontal_rule() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("---");
+ assertEquals("horizontalRule", doc.path("content").get(0).path("type").asText());
+ }
+
+ @Test
+ public void parse_hard_break_inside_paragraph() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("line1 \nline2");
+ final JsonNode para = doc.path("content").get(0);
+ boolean foundHardBreak = false;
+ for (final JsonNode child : para.path("content")) {
+ if ("hardBreak".equals(child.path("type").asText())) {
+ foundHardBreak = true;
+ break;
+ }
+ }
+ assertTrue("expected a hardBreak node inside paragraph", foundHardBreak);
+ }
+
+ @Test
+ public void parse_image_inline_in_paragraph() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("");
+ final JsonNode img = doc.path("content").get(0).path("content").get(0);
+ assertEquals("image", img.path("type").asText());
+ assertEquals("http://x/y.png", img.path("attrs").path("src").asText());
+ assertEquals("alt", img.path("attrs").path("alt").asText());
+ assertEquals("t", img.path("attrs").path("title").asText());
+ }
+
+ @Test
+ public void parse_gfm_table_produces_table_nodes_with_header_row() {
+ final String md = "| h1 | h2 |\n|----|----|\n| a | b |\n| c | d |";
+ final JsonNode doc = TiptapMarkdown.toTiptap(md);
+ final JsonNode table = doc.path("content").get(0);
+ assertEquals("table", table.path("type").asText());
+
+ final JsonNode rows = table.path("content");
+ assertEquals(3, rows.size());
+
+ // header row → tableHeader cells
+ final JsonNode headerRow = rows.get(0);
+ assertEquals("tableRow", headerRow.path("type").asText());
+ assertEquals("tableHeader", headerRow.path("content").get(0).path("type").asText());
+ assertEquals("h1", deepText(headerRow.path("content").get(0)));
+
+ // body rows → tableCell
+ final JsonNode bodyRow = rows.get(1);
+ assertEquals("tableCell", bodyRow.path("content").get(0).path("type").asText());
+ assertEquals("a", deepText(bodyRow.path("content").get(0)));
+
+ // cell attrs include colspan, rowspan, colwidth (null)
+ final JsonNode attrs = bodyRow.path("content").get(0).path("attrs");
+ assertEquals(1, attrs.path("colspan").asInt());
+ assertEquals(1, attrs.path("rowspan").asInt());
+ assertTrue("colwidth should be null", attrs.path("colwidth").isNull());
+ }
+
+ @Test
+ public void parse_nested_marks_bold_italic_link() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("[**bold link**](https://x)");
+ final JsonNode txt = findTextWithValue(doc, "bold link");
+ assertNotNull(txt);
+ assertHasMark(txt, "bold");
+ assertHasMark(txt, "link");
+ }
+
+ @Test
+ public void parse_multiple_paragraphs_separated_by_blank_lines() {
+ final JsonNode doc = TiptapMarkdown.toTiptap("first\n\nsecond\n\nthird");
+ assertEquals(3, doc.path("content").size());
+ for (final JsonNode p : doc.path("content")) {
+ assertEquals("paragraph", p.path("type").asText());
+ }
+ }
+
+ // =====================================================================
+ // Tiptap JSON -> Markdown
+ // =====================================================================
+
+ @Test
+ public void render_empty_doc_returns_empty_string() {
+ final ObjectNode doc = MAPPER.createObjectNode();
+ doc.put("type", "doc");
+ doc.putArray("content");
+ assertEquals("", TiptapMarkdown.toMarkdown(doc));
+ }
+
+ @Test
+ public void render_null_input_returns_empty_string() {
+ assertEquals("", TiptapMarkdown.toMarkdown((JsonNode) null));
+ }
+
+ @Test
+ public void render_heading_uses_hash_prefix_with_level() {
+ final String md = TiptapMarkdown.toMarkdown(TiptapMarkdown.toTiptap("### hello"));
+ assertTrue("expected `### hello` in output, got: " + md, md.startsWith("### hello"));
+ }
+
+ @Test
+ public void render_paragraph_emits_text_then_blank_line() {
+ final String md = TiptapMarkdown.toMarkdown(TiptapMarkdown.toTiptap("hello"));
+ assertEquals("hello", md.trim());
+ }
+
+ @Test
+ public void render_bold_uses_double_asterisks() {
+ final String md = TiptapMarkdown.toMarkdown(TiptapMarkdown.toTiptap("**x**"));
+ assertTrue(md.contains("**x**"));
+ }
+
+ @Test
+ public void render_italic_uses_single_asterisk() {
+ final String md = TiptapMarkdown.toMarkdown(TiptapMarkdown.toTiptap("*x*"));
+ assertTrue(md.contains("*x*"));
+ }
+
+ @Test
+ public void render_strike_uses_double_tilde() {
+ final String md = TiptapMarkdown.toMarkdown(TiptapMarkdown.toTiptap("~~x~~"));
+ assertTrue(md.contains("~~x~~"));
+ }
+
+ @Test
+ public void render_inline_code_uses_backticks_and_does_not_escape_specials() {
+ final String md = TiptapMarkdown.toMarkdown(TiptapMarkdown.toTiptap("`a*b_c`"));
+ // The text inside the code mark must remain literal — no backslash escapes.
+ assertTrue("code content must be literal, got: " + md, md.contains("`a*b_c`"));
+ }
+
+ @Test
+ public void render_link_includes_href_and_title() {
+ final String md = TiptapMarkdown.toMarkdown(
+ TiptapMarkdown.toTiptap("[x](https://dot.cms \"t\")"));
+ assertTrue(md.contains("[x](https://dot.cms \"t\")"));
+ }
+
+ @Test
+ public void render_blockquote_prefixes_each_line() {
+ final String md = TiptapMarkdown.toMarkdown(TiptapMarkdown.toTiptap("> a"));
+ assertTrue("expected blockquote prefix, got: " + md, md.startsWith("> "));
+ }
+
+ @Test
+ public void render_bullet_list_uses_dash_markers() {
+ final String md = TiptapMarkdown.toMarkdown(TiptapMarkdown.toTiptap("- a\n- b"));
+ assertTrue(md.contains("- a"));
+ assertTrue(md.contains("- b"));
+ }
+
+ @Test
+ public void render_ordered_list_starts_at_given_number() {
+ final String md = TiptapMarkdown.toMarkdown(TiptapMarkdown.toTiptap("5. a\n6. b"));
+ assertTrue("expected `5. a`, got: " + md, md.contains("5. a"));
+ assertTrue("expected `6. b`, got: " + md, md.contains("6. b"));
+ }
+
+ @Test
+ public void render_code_block_round_trips_language_and_body() {
+ final String src = "```java\nint x = 1;\n```";
+ final String md = TiptapMarkdown.toMarkdown(TiptapMarkdown.toTiptap(src));
+ assertTrue(md.contains("```java"));
+ assertTrue(md.contains("int x = 1;"));
+ assertTrue(md.trim().endsWith("```"));
+ }
+
+ @Test
+ public void render_code_block_picks_longer_fence_when_body_contains_triple_backticks() {
+ final ObjectNode doc = MAPPER.createObjectNode();
+ doc.put("type", "doc");
+ final ObjectNode cb = doc.putArray("content").addObject();
+ cb.put("type", "codeBlock");
+ cb.putArray("content").addObject().put("type", "text").put("text", "look ``` here");
+ final String md = TiptapMarkdown.toMarkdown(doc);
+ // Triple-backtick fence would collide with body — must use 4+ backticks.
+ assertTrue("expected fence with 4+ backticks, got: " + md, md.startsWith("````"));
+ }
+
+ @Test
+ public void render_horizontal_rule_uses_three_dashes() {
+ final String md = TiptapMarkdown.toMarkdown(TiptapMarkdown.toTiptap("---"));
+ assertEquals("---", md.trim());
+ }
+
+ @Test
+ public void render_image_emits_bang_bracket_form() {
+ final String md = TiptapMarkdown.toMarkdown(
+ TiptapMarkdown.toTiptap(""));
+ assertTrue(md.contains(""));
+ }
+
+ @Test
+ public void render_table_produces_pipe_delimited_rows_with_separator() {
+ final String src = "| h1 | h2 |\n|----|----|\n| a | b |";
+ final String md = TiptapMarkdown.toMarkdown(TiptapMarkdown.toTiptap(src));
+ final String[] lines = md.trim().split("\n");
+ assertTrue("expected at least 3 lines, got: " + md, lines.length >= 3);
+ assertTrue("header row must start with |", lines[0].trim().startsWith("|"));
+ assertTrue("separator row must contain ---", lines[1].contains("---"));
+ assertTrue("body row must include `a`", lines[2].contains("a"));
+ assertTrue("body row must include `b`", lines[2].contains("b"));
+ }
+
+ @Test
+ public void render_table_cell_escapes_pipe_character() {
+ final ObjectNode doc = MAPPER.createObjectNode();
+ doc.put("type", "doc");
+ final ObjectNode table = doc.putArray("content").addObject();
+ table.put("type", "table");
+ final ObjectNode row = table.putArray("content").addObject();
+ row.put("type", "tableRow");
+ final ObjectNode cell = row.putArray("content").addObject();
+ cell.put("type", "tableHeader");
+ final ObjectNode para = cell.putArray("content").addObject();
+ para.put("type", "paragraph");
+ para.putArray("content").addObject().put("type", "text").put("text", "a|b");
+
+ final String md = TiptapMarkdown.toMarkdown(doc);
+ assertTrue("pipe inside cell must be escaped, got: " + md, md.contains("a\\|b"));
+ }
+
+ @Test
+ public void render_text_escapes_markdown_specials_outside_code() {
+ final ObjectNode doc = MAPPER.createObjectNode();
+ doc.put("type", "doc");
+ final ObjectNode p = doc.putArray("content").addObject();
+ p.put("type", "paragraph");
+ p.putArray("content").addObject().put("type", "text").put("text", "a*b_c[d]");
+
+ final String md = TiptapMarkdown.toMarkdown(doc);
+ // Each markdown special must be backslash-escaped so the regen markdown stays literal.
+ assertTrue("expected escaped *, got: " + md, md.contains("\\*"));
+ assertTrue("expected escaped _, got: " + md, md.contains("\\_"));
+ assertTrue("expected escaped [, got: " + md, md.contains("\\["));
+ assertTrue("expected escaped ], got: " + md, md.contains("\\]"));
+ }
+
+ @Test
+ public void render_string_overload_parses_json_and_returns_markdown() {
+ final String json = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\","
+ + "\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}";
+ assertEquals("hi", TiptapMarkdown.toMarkdown(json));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void render_string_overload_throws_on_invalid_json() {
+ TiptapMarkdown.toMarkdown("not json");
+ }
+
+ // =====================================================================
+ // Round-trip stability
+ // =====================================================================
+
+ @Test
+ public void roundtrip_paragraph_with_marks_is_stable() {
+ assertRoundTripStable("**bold** and *italic* and ~~strike~~ and `code`.");
+ }
+
+ @Test
+ public void roundtrip_heading_is_stable() {
+ assertRoundTripStable("## A heading");
+ }
+
+ @Test
+ public void roundtrip_link_is_stable() {
+ assertRoundTripStable("see [docs](https://dotcms.com).");
+ }
+
+ @Test
+ public void roundtrip_code_block_is_stable() {
+ assertRoundTripStable("```java\nint x = 1;\n```");
+ }
+
+ @Test
+ public void roundtrip_blockquote_is_stable() {
+ assertRoundTripStable("> quoted text");
+ }
+
+ @Test
+ public void roundtrip_horizontal_rule_is_stable() {
+ assertRoundTripStable("---");
+ }
+
+ @Test
+ public void roundtrip_image_is_stable() {
+ assertRoundTripStable("");
+ }
+
+ @Test
+ public void roundtrip_bullet_list_is_stable() {
+ // Once normalized through one full round-trip the structure must be stable
+ // (whitespace/cosmetic differences are absorbed by the first pass).
+ final JsonNode once = TiptapMarkdown.toTiptap(TiptapMarkdown.toMarkdown(
+ TiptapMarkdown.toTiptap("- a\n- b\n- c")));
+ final JsonNode twice = TiptapMarkdown.toTiptap(TiptapMarkdown.toMarkdown(once));
+ assertEquals(once, twice);
+ }
+
+ @Test
+ public void roundtrip_ordered_list_is_stable() {
+ final JsonNode once = TiptapMarkdown.toTiptap(TiptapMarkdown.toMarkdown(
+ TiptapMarkdown.toTiptap("1. a\n2. b")));
+ final JsonNode twice = TiptapMarkdown.toTiptap(TiptapMarkdown.toMarkdown(once));
+ assertEquals(once, twice);
+ }
+
+ @Test
+ public void roundtrip_table_is_stable() {
+ final String md = "| h1 | h2 |\n|----|----|\n| a | b |\n| c | d |";
+ final JsonNode once = TiptapMarkdown.toTiptap(TiptapMarkdown.toMarkdown(
+ TiptapMarkdown.toTiptap(md)));
+ final JsonNode twice = TiptapMarkdown.toTiptap(TiptapMarkdown.toMarkdown(once));
+ assertEquals(once, twice);
+ }
+
+ // =====================================================================
+ // helpers
+ // =====================================================================
+
+ /** Assert that two round-trips produce the same JSON (semantic stability). */
+ private static void assertRoundTripStable(final String markdown) {
+ final JsonNode once = TiptapMarkdown.toTiptap(
+ TiptapMarkdown.toMarkdown(TiptapMarkdown.toTiptap(markdown)));
+ final JsonNode twice = TiptapMarkdown.toTiptap(
+ TiptapMarkdown.toMarkdown(once));
+ assertEquals("round-trip should be stable for: " + markdown, once, twice);
+ }
+
+ /** Recursively search for a text node whose `text` equals the given value. */
+ private static JsonNode findTextWithValue(final JsonNode root, final String value) {
+ if (root == null) return null;
+ if ("text".equals(root.path("type").asText()) && value.equals(root.path("text").asText())) {
+ return root;
+ }
+ for (final JsonNode c : root.path("content")) {
+ final JsonNode hit = findTextWithValue(c, value);
+ if (hit != null) return hit;
+ }
+ return null;
+ }
+
+ /** Recursively concatenate all `text` nodes' content under a node. */
+ private static String deepText(final JsonNode root) {
+ final StringBuilder sb = new StringBuilder();
+ collectText(root, sb);
+ return sb.toString();
+ }
+
+ private static void collectText(final JsonNode n, final StringBuilder sb) {
+ if (n == null) return;
+ if ("text".equals(n.path("type").asText())) {
+ sb.append(n.path("text").asText());
+ }
+ for (final JsonNode c : n.path("content")) collectText(c, sb);
+ }
+
+ private static void assertHasMark(final JsonNode textNode, final String markType) {
+ assertNotNull("expected text node carrying mark " + markType, textNode);
+ assertNotNull("text node must have a `marks` array for " + markType,
+ firstMarkOfType(textNode, markType));
+ }
+
+ private static JsonNode firstMarkOfType(final JsonNode textNode, final String type) {
+ if (textNode == null || !textNode.has("marks")) return null;
+ for (final JsonNode m : textNode.get("marks")) {
+ if (type.equals(m.path("type").asText())) return m;
+ }
+ return null;
+ }
+
+ private static String repeat(final char c, final int n) {
+ final char[] a = new char[n];
+ java.util.Arrays.fill(a, c);
+ return new String(a);
+ }
+}
diff --git a/dotCMS/src/test/resources/blog-test.json b/dotCMS/src/test/resources/blog-test.json
new file mode 100644
index 000000000000..39c07269c045
--- /dev/null
+++ b/dotCMS/src/test/resources/blog-test.json
@@ -0,0 +1,3922 @@
+{
+ "contentlets": [
+ {
+ "title": "Migrating Your OSGi Plugins to dotEvergreen: Adapting to the New Index API",
+ "body": {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "dotCMS is continuously evolving to stay secure, modern, and maintainable. One of the most significant recent changes involves our migration away from Elasticsearch toward "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "OpenSearch 3.0"
+ },
+ {
+ "type": "text",
+ "text": " as our index provider. This transition required deep refactoring inside our core, and if you maintain custom OSGi plugins that interact with the indexing layer, this post is for you."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ }
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Why Is This Happening?"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Elasticsearch Has Reached End of Life"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Elasticsearch 7.x — the version dotCMS historically shipped with — reached "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "full end of life on January 15, 2026"
+ },
+ {
+ "type": "text",
+ "text": ". With EOL comes a halt in security patches, leaving any system running it exposed to known vulnerabilities. Indeed, Elasticsearch 7.x carries multiple documented CVEs, including authentication bypass issues (Field-Level Security), memory disclosure bugs in the 7.10–7.13 range, and denial-of-service vulnerabilities via the Grok parser."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Beyond security, Elastic changed its licensing model in 2021, moving away from the open Apache 2.0 license to the "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Server Side Public License (SSPL)"
+ },
+ {
+ "type": "text",
+ "text": " and its own proprietary Elastic License, neither of which are OSI-approved open-source licenses. This has long-term implications for any platform that bundles Elasticsearch as a core dependency."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": " "
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "The Move to OpenSearch 3.0"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "OpenSearch, the community-driven fork of Elasticsearch maintained under the Apache 2.0 license, released "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "OpenSearch 3.0 in December 2025"
+ },
+ {
+ "type": "text",
+ "text": ". It brings major performance improvements (up to "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "https://opensearch.org/announcements/opensearch-3-0-enhances-vector-database-performance/",
+ "target": "_blank",
+ "rel": "noopener noreferrer nofollow",
+ "class": null,
+ "title": null,
+ "aria-label": null
+ }
+ },
+ {
+ "type": "underline"
+ }
+ ],
+ "text": "9.5× over earlier versions"
+ },
+ {
+ "type": "text",
+ "text": "), modern ingestion capabilities, and gRPC support — making it the right foundation for dotCMS going forward. "
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "This comes at the perfect time; we are currently in the process of deprecating our dependency on Java 11 in favor of Java 25. This modernization can be seen as a direct continuation of that one, which we "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "https://www.dotcms.com/blog/2026-engineering-update-roadmap-java-25-and-infrastructure-modernization",
+ "target": "_blank",
+ "rel": "noopener noreferrer nofollow",
+ "class": null,
+ "title": null,
+ "aria-label": null
+ }
+ }
+ ],
+ "text": "discussed at length in a December blog post"
+ },
+ {
+ "type": "text",
+ "text": ". "
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": " "
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Paying Technical Debt While Modernizing"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "The migration to OpenSearch 3.0 is also an opportunity to pay down years of technical debt. Our core was tightly coupled to Elasticsearch-specific types — types that don't exist in OpenSearch or any other index provider. To support OpenSearch 3.0 (and potentially other providers in parallel), we introduced a "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "provider-neutral domain layer"
+ },
+ {
+ "type": "text",
+ "text": " for all indexing abstractions."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "This is a compile-time change. If your plugin references the old Elasticsearch-specific classes directly, it will "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "fail to load at runtime"
+ },
+ {
+ "type": "text",
+ "text": " against dotEvergreen."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ }
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Is Your Plugin Affected?"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Your plugin is affected if it imports any classes from the "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "org.elasticsearch.*"
+ },
+ {
+ "type": "text",
+ "text": " namespace or return types deprecated dotCMS APIs tied to Elasticsearch, such as that of "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "APILocator.getESIndexAPI()"
+ },
+ {
+ "type": "text",
+ "text": "."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "The first sign you will see — if you deploy an unmodified plugin — is a runtime crash like this:"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Caused by: java.lang.NoSuchMethodError:\n 'com.dotcms.content.elasticsearch.business.ESIndexAPI\n com.dotmarketing.business.APILocator.getESIndexAPI()'"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "This error means the method signature no longer exists; the class "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "ESIndexAPI"
+ },
+ {
+ "type": "text",
+ "text": " has been removed. "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "This change was introduced starting with dotCMS version 26.03.06-02."
+ },
+ {
+ "type": "text",
+ "text": " We recommend always building against the latest available release."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "The fix requires recompiling your plugin against the new dotCMS core API. Here is how to do that."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ }
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Step 1: Configure Your Maven Project"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "dotCMS core is built with Maven, and our artifacts are published to our Artifactory repository. While you can use other build tools, "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Maven is strongly recommended"
+ },
+ {
+ "type": "text",
+ "text": " for OSGi plugin development as it integrates cleanly with the "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "maven-bundle-plugin"
+ },
+ {
+ "type": "text",
+ "text": " (Apache Felix) that generates the OSGi bundle manifest."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": " "
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Minimum pom.xml skeleton"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Below is a clean, ready-to-use skeleton for a dotCMS OSGi plugin project. Replace the "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "groupId"
+ },
+ {
+ "type": "text",
+ "text": ", "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "artifactId"
+ },
+ {
+ "type": "text",
+ "text": ", and bundle metadata with your own values."
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "\n 4.0.0\n\n \n com.example.dotcms.plugin\n my-OSGi-plugin\n 1.0.0-SNAPSHOT\n bundle\n\n \n 1.0.0\n UTF-8\n 11\n 11\n\n \n 26.03.20-01\n \n\n \n \n \n dotcms-repo\n https://artifactory.dotcms.cloud/artifactory/libs-release\n \n \n\n \n \n \n com.dotcms\n dotcms-core\n ${dotcms-core.version}\n provided\n \n\n \n \n\n \n \n \n \n org.apache.felix\n maven-bundle-plugin\n 5.1.9\n true\n \n \n Your Company Name\n My OSGi Plugin\n ${bundle.version}\n Short description of what this plugin does\n com.example.dotcms.plugin.Activator\n com.example.dotcms.plugin\n *;resolution:=optional\n \n *;scope=compile|runtime;inline=true\n true\n \n \n \n \n \n\n"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 40,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Note:"
+ },
+ {
+ "type": "text",
+ "text": " "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "dotcms-core"
+ },
+ {
+ "type": "text",
+ "text": " must be declared with "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "provided"
+ },
+ {
+ "type": "text",
+ "text": ". It is available in the dotCMS runtime environment and must "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "not"
+ },
+ {
+ "type": "text",
+ "text": " be embedded inside your bundle JAR."
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Build your plugin"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "mvn clean package"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ }
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Step 2: Expect and Fix Compilation Errors"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Once you update your "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "dotcms-core.version"
+ },
+ {
+ "type": "text",
+ "text": " to the latest release and run "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "mvn compile"
+ },
+ {
+ "type": "text",
+ "text": ", you will likely see errors like these:"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "[ERROR] COMPILATION ERROR :\n[ERROR] .../Indexer.java:[102,49]\n incompatible types:\n com.dotcms.content.index.IndexAPI\n cannot be converted to\n com.dotcms.content.elasticsearch.business.ESIndexAPI\n\n[ERROR] .../Indexer.java:[377,66]\n incompatible types:\n com.dotcms.content.index.domain.CreateIndexStatus\n cannot be converted to\n org.elasticsearch.client.indices.CreateIndexResponse"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "These are "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "your migration checklist"
+ },
+ {
+ "type": "text",
+ "text": ". Each compilation error pinpoints exactly where an Elasticsearch-specific type needs to be replaced with its dotCMS domain equivalent. This is by design; the compiler is your guide."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ }
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Step 3: Update Your Imports and Types"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "The New Namespace"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "All provider-neutral indexing abstractions now live under:"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "com.dotcms.content.index.domain"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "The class names were intentionally kept as close as possible to the originals, so mapping old types to new ones is straightforward. Here are the most common substitutions:"
+ }
+ ]
+ },
+ {
+ "type": "table",
+ "content": [
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": "center"
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Before (Elasticsearch-specific)"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": "center"
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "After (dotCMS domain-neutral)"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "org.elasticsearch.client.indices.CreateIndexResponse"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "com.dotcms.content.index.domain.CreateIndexStatus"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "com.dotcms.content.elasticsearch.business.ESIndexAPI"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "com.dotcms.content.index.IndexAPI"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": " "
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Example: Updating an Index Operation"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 4
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Before:"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "import org.elasticsearch.client.indices.CreateIndexResponse;\nimport com.dotcms.content.elasticsearch.business.ESIndexAPI;\n\n// ...\n\nESIndexAPI indexAPI = APILocator.getESIndexAPI();\nCreateIndexResponse response = indexAPI.createIndex(indexName, settings);\n\nif (response.isAcknowledged()) {\n // index was created\n}"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": " "
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 4
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "After:"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "import com.dotcms.content.index.domain.CreateIndexStatus;\nimport com.dotcms.content.index.IndexAPI;\n\n// ...\n\nIndexAPI indexAPI = APILocator.getESIndexAPI();\nCreateIndexStatus status = indexAPI.createIndex(indexName, settings);\n\nif (status.isAcknowledged()) {\n // index was created\n}"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "The method semantics remain the same; only the types change."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ }
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Step 4: Test Against dotEvergreen"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "After resolving all compilation errors, deploy your rebuilt bundle to a dotEvergreen instance and verify:"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "type": null
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "The plugin activates without errors in the OSGi console."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "All indexing-related functionality behaves as expected."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "No "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "NoSuchMethodError"
+ },
+ {
+ "type": "text",
+ "text": " or "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "ClassNotFoundException"
+ },
+ {
+ "type": "text",
+ "text": " appears in the logs."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "A Note From the dotCMS Team"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "We want to be transparent: "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "This is a breaking change"
+ },
+ {
+ "type": "text",
+ "text": " that we are introducing, and we understand it creates work on your end. We don't take that lightly."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "This refactoring is part of a necessary and long-overdue effort to modernize dotCMS's core, remove dependencies on end-of-life software, and build a more secure and sustainable platform for everyone. We know that \"necessary\" doesn't make it painless, and we genuinely appreciate your patience and understanding as we work through this transition together."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "We are committed to making the migration path as clear as possible — and we are here to help every step of the way."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ }
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "What Has Already Changed"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "The following classes have had their method signatures updated as of 26.03.06-02:"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "content": [
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "com.dotcms.content.elasticsearch.business.ESMappingAPIImpl"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "com.dotmarketing.portlets.contentlet.business.ContentletFactory"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "com.dotcms.content.elasticsearch.business.ContentletIndexAPI"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ }
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "What's Coming Next"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "The refactoring is ongoing. The following classes are expected to have signature changes in upcoming releases; we recommend keeping an eye on the release notes if your plugin interacts with any of them:"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "content": [
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "ESIndexAPI"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "ESContentFactoryImpl"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "ESMappingAPIImpl"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "ContentletIndexAPIImpl"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "ReindexThread"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "PermissionBitFactoryImpl"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "ESSearchAPIImpl"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "ESSiteSearchAPI"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "ESContentletAPIImpl"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "ContentTypeAPIImpl"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "ClusterUtil"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "ESContentletScrollImpl"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "This list will be updated as changes are confirmed. When in doubt, compile early and compile often against the latest dotCMS release; the compiler will tell you exactly what needs attention."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ }
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Summary"
+ }
+ ]
+ },
+ {
+ "type": "table",
+ "content": [
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": "center"
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "What changed"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": "center"
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Where"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": "center"
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Since version"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "ESIndexAPI"
+ },
+ {
+ "type": "text",
+ "text": " removed"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "com.dotcms.content.elasticsearch.business"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "26.03.06-02"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "New "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "IndexAPI"
+ },
+ {
+ "type": "text",
+ "text": " introduced"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "com.dotcms.content.index"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "26.03.06-02"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "New domain objects"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "com.dotcms.content.index.domain"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "26.03.06-02"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "The bottom line: Update your "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "dotcms-core"
+ },
+ {
+ "type": "text",
+ "text": " dependency version, compile, follow the compiler errors, replace each Elasticsearch-specific type with its "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "com.dotcms.content.index.domain"
+ },
+ {
+ "type": "text",
+ "text": " equivalent, and you are done."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "If you run into issues or have questions about specific API mappings, reach out to dotCMS Support — we're here to help."
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ }
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "dotCMS Engineering Team"
+ }
+ ]
+ }
+ ],
+ "attrs": {
+ "charCount": 11449,
+ "wordCount": 1226,
+ "readingTime": 5
+ }
+ }
+ },
+ {
+ "title": "How Enterprise Teams Scale Multi-Site CMS Architectures Without Chaos",
+ "body": {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "When you’re building and managing thousands of websites for a distributed network of partners, the key challenge isn’t just scale, it’s control. How do you give each site its own digital identity while keeping the experience cohesive and manageable?"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "In this post, "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "https://solvisse.com/",
+ "target": "_blank",
+ "rel": "noopener noreferrer nofollow",
+ "class": null,
+ "title": null,
+ "aria-label": null
+ }
+ },
+ {
+ "type": "underline"
+ }
+ ],
+ "text": "Solvisse"
+ },
+ {
+ "type": "text",
+ "text": " unpacks how they engineered a multi-site architecture on top of dotCMS to "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "https://www.dotcms.com/blog/transforming-over-3000-websites-with-solvisse-and-dotcms",
+ "target": "_blank",
+ "rel": "noopener noreferrer nofollow",
+ "class": null,
+ "title": null,
+ "aria-label": null
+ }
+ }
+ ],
+ "text": "support thousands of partner websites"
+ },
+ {
+ "type": "text",
+ "text": ", without drowning in complexity."
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Why Solvisse Chose dotCMS for the Core"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "dotCMS offered the hybrid flexibility Solvisse needed: structured content models, headless APIs, and multitenancy support via “hosts.” This was critical. We didn’t just want to clone sites—we wanted a single source of truth with modularity and reusability baked in."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "At the heart of our implementation sits a centralized dotCMS instance powering every site. But to make it work at this volume, we had to design a layered system that logically separates responsibilities while encouraging shared components."
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Hosts: The Building Blocks of Multitenancy"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "In dotCMS, each Host represents a logically distinct website. Think of it as a semi-isolated tenant within the main instance. Each partner website is mapped to its own host, allowing content, templates, and metadata to be scoped appropriately."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": " "
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Types of Hosts in Our System:"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "content": [
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Partner Sites:"
+ },
+ {
+ "type": "text",
+ "text": " Individually branded, editable by the partner via "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "https://www.dotcms.com/blog/transforming-over-3000-websites-with-solvisse-and-dotcms",
+ "target": "_blank",
+ "rel": "noopener noreferrer nofollow",
+ "class": null,
+ "title": null,
+ "aria-label": null
+ }
+ },
+ {
+ "type": "underline"
+ }
+ ],
+ "text": "PartnerCMS"
+ },
+ {
+ "type": "text",
+ "text": "."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Core Host: "
+ },
+ {
+ "type": "text",
+ "text": "Contains shared templates, containers, reusable content, shared pages, and global header/footer logic."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Shared Host:"
+ },
+ {
+ "type": "text",
+ "text": " Houses custom content shared across partner groups (e.g., editorial content, localized banners)."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Seed Sites: "
+ },
+ {
+ "type": "text",
+ "text": "Template sites used to bootstrap new hosts with brand-specific default content and structure."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Content Engineering: Structured for Reuse, Built for Flexibility"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Each host follows a consistent structure and relies on contentlets, containers, and templates to define and display content. Here’s how Solvisse made that scalable:"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": " "
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Content Types & Contentlets"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "content": [
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Strict content types were defined to model each component (e.g., banners, tiles, videos, search widgets)."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "These types allow both marketing and partners to reuse logic and avoid duplication."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Contentlets are the individual instances of these types, scoped either locally or shared across hosts."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": " "
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Templates & Containers"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "content": [
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Templates define page layout; containers identify and manage content zones (e.g., carousels, sidebars, CTAs)."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Containers are centrally maintained in the Core host and referenced across all other hosts."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Each maps to a .vtl file that governs how the content is rendered and injected dynamically."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "This model minimizes design drift and simplifies global design updates."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "First-Level Contents"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "A key architectural decision was the introduction of First-Level Content—a foundational layer of contentlets created on every site. These contentlets are designed to be persistent and non-deletable, forming the structural entry points for each page. Their primary function is to reference Second-Level Content, which can be either shared or localized—such as images, videos, and banners."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "By anchoring each page to a predictable set of First-Level Content, we ensure consistent layout and query behavior across the ecosystem. For instance, a BannerCarousel contentlet holds configuration data like autoplay behavior and display titles while referencing a set of images that define the actual banner visuals. This layered approach enforces structure while allowing partners to swap out content within predefined safe zones."
+ }
+ ]
+ },
+ {
+ "type": "dotImage",
+ "attrs": {
+ "textAlign": null,
+ "src": "/dA/test-placeholder/1.png",
+ "alt": null,
+ "title": null,
+ "href": null,
+ "data": {},
+ "target": null,
+ "textWrap": null
+ }
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Shared vs. Local Content"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Each site blends shared content from the Core host with local, partner-specific content. dotCMS’s cross-host referencing capabilities allow this to happen seamlessly, enabling reuse and scalability."
+ }
+ ]
+ },
+ {
+ "type": "table",
+ "content": [
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": "center",
+ "level": 4
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Shared Content"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": "center",
+ "level": 4
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Local Content"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Banners, Header/Footer"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "About Us, Contact Info, Metadata"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Tiles, Search Widget"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Custom Video Spotlight, Blog Posts"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Global Navigation (Mega Menu)"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Custom Articles via PartnerCMS"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Shared Pages and Virtual Presentation"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Instead of duplicating static pages across thousands of sites, we implemented a virtual path strategy to balance scalability with editorial control. Pages under the /vacation path represent centrally authored, globally syndicated content. Pages under /editorial are created by partners but rendered as if they exist under their own subdomains."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "To make this possible, we built a set of custom interceptors via an OSGi plugin for dotCMS. These interceptors hook into the request pipeline and analyze the HTTP path and host header. For requests to /vacation or /editorial, the plugin identifies the content source, fetches the corresponding object (regardless of its physical host), and renders it dynamically in the context of the requesting domain."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "This plugin-based approach allows us to decouple content storage from content presentation. Marketing maintains centralized control over shared assets, while partners create and publish content that appears native to their site. The result is a clean, multi-tenant user experience without duplicating assets or hardcoding logic."
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Rendering Logic: VTL and Dynamic Resolution"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Each page request follows a standardized flow:"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "type": null
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "WAF Layer"
+ },
+ {
+ "type": "text",
+ "text": " (e.g., Imperva) inspects and secures incoming traffic."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "dotCMS Resolver"
+ },
+ {
+ "type": "text",
+ "text": " determines the correct host using the preserved host header."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "VTL Rendering "
+ },
+ {
+ "type": "text",
+ "text": "dynamically builds the page from containers and contentlets."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": " "
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Example:"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "GET jdoe.mycompany.com/about-us\n\n→ routed to dotCMS\n\n→ host header = jdoe.mycompany.com\n\n→ renders /about-us from the corresponding host context"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "This system supports clean URLs, SEO-friendly structure, and scoped content resolution—regardless of content location."
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Meta Tags: Structured, Scoped, and Automated"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "To ensure SEO compliance at scale, we implemented a three-tier meta tag system: Global, Brand, and Page. Each layer is editable in a controlled way depending on its scope."
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "content": [
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Global Tags"
+ },
+ {
+ "type": "text",
+ "text": " (e.g., viewport, charset) are injected via a shared VTL file, which is rendered on every site."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Brand-Level Tags"
+ },
+ {
+ "type": "text",
+ "text": " (e.g., og:brand) live in a dedicated brand host and apply across all sites under that brand."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Page-Level Tags"
+ },
+ {
+ "type": "text",
+ "text": " are configurable by the partner via PartnerCMS—empowering them to manage titles and descriptions for pages they control."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "For shared content like marketing editorials, the central marketing team manages metadata directly in dotCMS using structured contentlets. This model enables both consistency and flexibility while maintaining strict governance."
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Creating New Sites in Minutes"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "New partner sites are provisioned automatically through an orchestrated pipeline driven by brand-specific Seed Sites and a set of background CRON jobs."
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "type": null
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "The system clones the appropriate Seed Site and initializes default structure, pages, and containers."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "A domain is assigned and registered as the site’s unique key."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Scheduled CRON jobs populate partnerdata and siteconfig contentlets via secure external API calls, injecting identity, branding, and business logic."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Additional CRON-driven jobs fetch rendered headers and footers from dotCMS, then cache them in Redis for rapid delivery across multiple platforms."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Within minutes, the new site is live, fully branded, and ready for customization by the partner. This modular automation pipeline enables rapid, repeatable, and reliable onboarding at scale."
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Conclusions"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Building a robust multi-site platform for a distributed network of partners requires a strategic blend of architectural foresight and precise content engineering. By leveraging dotCMS’s hybrid capabilities, specifically its host-based multitenancy, structured content models, and extensible API, we at "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "https://www.dotcms.com/partners/solvisse",
+ "target": "_blank",
+ "rel": "noopener noreferrer nofollow",
+ "class": null,
+ "title": null,
+ "aria-label": null
+ }
+ },
+ {
+ "type": "underline"
+ }
+ ],
+ "text": "Solvisse"
+ },
+ {
+ "type": "text",
+ "text": " created a system that balances centralized control with localized flexibility."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Our layered approach, from the strategic use of First-Level Content and Seed Sites to the implementation of virtual presentation and automated provisioning, demonstrates how to manage complexity while enabling rapid deployment and consistent branding across thousands of independent digital presences. This architecture not only solves the scale challenge but also empowers partners to tailor their digital identity within a well-governed framework, ultimately driving efficiency and brand cohesion across the entire network."
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "indent": 0,
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "https://solvisse.com/insights/dotcms-content-engineering-the-multi-site-strategy-deep-dive/",
+ "target": "_blank",
+ "rel": "noopener noreferrer nofollow",
+ "class": null,
+ "title": null,
+ "aria-label": null
+ }
+ },
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Visit Solvisse Insights and Stories to learn more. "
+ }
+ ]
+ }
+ ],
+ "attrs": {
+ "charCount": 8175,
+ "wordCount": 1173,
+ "readingTime": 5
+ }
+ }
+ }
+ ]
+}
\ No newline at end of file