diff --git a/.gitignore b/.gitignore
index e21cc92..9fdd99d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -260,6 +260,9 @@ gradle-app.setting
sample/
+# Local-only Spring Boot LP demo (not committed by default)
+sample-lp-demo/
+
# End of https://www.toptal.com/developers/gitignore/api/macos,code-java,java-web,maven,gradle,intellij,visualstudiocode,eclipse
.idea/compiler.xml
.idea/encodings.xml
diff --git a/Changelog.md b/Changelog.md
index 3bae033..ae598d0 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -1,6 +1,13 @@
# Changelog
A brief description of what changes project contains
+
+## Apr 20, 2026
+
+#### v1.5.0
+
+- Enhancement: Live Preview Editable tags
+
## Mar 23, 2026
#### v1.4.0
diff --git a/pom.xml b/pom.xml
index 3f15ef1..c643cf7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
4.0.0
com.contentstack.sdk
utils
- 1.4.0
+ 1.5.0
jar
Contentstack-utils
Java Utils SDK for Contentstack Content Delivery API, Contentstack is a headless CMS
diff --git a/src/main/java/com/contentstack/utils/EditableTags.java b/src/main/java/com/contentstack/utils/EditableTags.java
new file mode 100644
index 0000000..cc38868
--- /dev/null
+++ b/src/main/java/com/contentstack/utils/EditableTags.java
@@ -0,0 +1,351 @@
+package com.contentstack.utils;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Live Preview editable tags (CSLP) — parity with contentstack-utils-javascript
+ * {@code entry-editable.ts}.
+ */
+public final class EditableTags {
+
+ /**
+ * Variant / meta-key state threaded through {@link #getTag(Object, String, boolean, String, AppliedVariantsState)}.
+ */
+ public static final class AppliedVariantsState {
+ private final JSONObject appliedVariants;
+ private final boolean shouldApplyVariant;
+ private final String metaKey;
+
+ public AppliedVariantsState(JSONObject appliedVariants, boolean shouldApplyVariant, String metaKey) {
+ this.appliedVariants = appliedVariants;
+ this.shouldApplyVariant = shouldApplyVariant;
+ this.metaKey = metaKey != null ? metaKey : "";
+ }
+
+ public JSONObject getAppliedVariants() {
+ return appliedVariants;
+ }
+
+ public boolean isShouldApplyVariant() {
+ return shouldApplyVariant;
+ }
+
+ public String getMetaKey() {
+ return metaKey;
+ }
+ }
+
+ private EditableTags() {
+ }
+
+ /**
+ * Adds Contentstack Live Preview (CSLP) data tags to an entry for editable UIs.
+ * Mutates the entry by attaching a {@code $} property with tag strings or objects
+ * ({@code data-cslp} / {@code data-cslp-parent-field}) for each field.
+ *
+ * @param entry CDA-style entry JSON (must not be {@code null}); must contain {@code uid}
+ * @param contentTypeUid content type UID (e.g. {@code blog_post})
+ * @param tagsAsObject if {@code true}, tags are JSON objects; if {@code false}, {@code data-cslp=...} strings
+ * @param locale locale code (default in overloads: {@code en-us})
+ * @param options optional; controls locale casing (default lowercases locale)
+ */
+ public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale,
+ EditableTagsOptions options) {
+ if (entry == null) {
+ return;
+ }
+ boolean useLowerCaseLocale = true;
+ if (options != null) {
+ useLowerCaseLocale = options.isUseLowerCaseLocale();
+ }
+ String ct = contentTypeUid == null ? "" : contentTypeUid.toLowerCase();
+ String loc = locale == null ? "en-us" : locale;
+ if (useLowerCaseLocale) {
+ loc = loc.toLowerCase();
+ }
+ JSONObject applied = entry.optJSONObject("_applied_variants");
+ if (applied == null) {
+ JSONObject system = entry.optJSONObject("system");
+ if (system != null) {
+ applied = system.optJSONObject("applied_variants");
+ }
+ }
+ boolean shouldApply = applied != null;
+ String uid = entry.optString("uid", "");
+ String prefix = ct + "." + uid + "." + loc;
+ AppliedVariantsState state = new AppliedVariantsState(applied, shouldApply, "");
+ entry.put("$", getTag(entry, prefix, tagsAsObject, loc, state));
+ }
+
+ /**
+ * @see #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)
+ */
+ public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject) {
+ addEditableTags(entry, contentTypeUid, tagsAsObject, "en-us", null);
+ }
+
+ /**
+ * @see #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)
+ */
+ public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale) {
+ addEditableTags(entry, contentTypeUid, tagsAsObject, locale, null);
+ }
+
+ /**
+ * Alias for {@link #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)} — matches JS
+ * {@code addTags}.
+ */
+ public static void addTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale,
+ EditableTagsOptions options) {
+ addEditableTags(entry, contentTypeUid, tagsAsObject, locale, options);
+ }
+
+ /**
+ * Recursive tag map for the given content (entry object or array). Exposed for parity with JS tests.
+ *
+ * @param content {@link JSONObject}, {@link JSONArray}, or null
+ * @param prefix path prefix ({@code contentTypeUid.entryUid.locale...})
+ * @param tagsAsObject string vs object tag form
+ * @param locale locale for reference entries
+ * @param appliedVariants variant state
+ * @return map of field keys to tag string or tag object
+ */
+ public static JSONObject getTag(Object content, String prefix, boolean tagsAsObject, String locale,
+ AppliedVariantsState appliedVariants) {
+ if (content == null || JSONObject.NULL.equals(content)) {
+ return new JSONObject();
+ }
+ if (content instanceof JSONArray) {
+ return getTagForArray((JSONArray) content, prefix, tagsAsObject, locale, appliedVariants);
+ }
+ if (content instanceof JSONObject) {
+ return getTagForJSONObject((JSONObject) content, prefix, tagsAsObject, locale, appliedVariants);
+ }
+ return new JSONObject();
+ }
+
+ private static JSONObject getTagForJSONObject(JSONObject content, String prefix, boolean tagsAsObject,
+ String locale, AppliedVariantsState appliedVariants) {
+ JSONObject tags = new JSONObject();
+ Iterator keys = content.keys();
+ while (keys.hasNext()) {
+ String key = keys.next();
+ handleKey(tags, key, content.opt(key), prefix, tagsAsObject, locale, appliedVariants);
+ }
+ return tags;
+ }
+
+ private static JSONObject getTagForArray(JSONArray content, String prefix, boolean tagsAsObject, String locale,
+ AppliedVariantsState appliedVariants) {
+ JSONObject tags = new JSONObject();
+ for (int i = 0; i < content.length(); i++) {
+ String key = Integer.toString(i);
+ handleKey(tags, key, content.opt(i), prefix, tagsAsObject, locale, appliedVariants);
+ }
+ return tags;
+ }
+
+ /** One entry from {@code Object.entries} — same structure for {@link JSONObject} and {@link JSONArray}. */
+ private static void handleKey(JSONObject tags, String key, Object value, String prefix, boolean tagsAsObject,
+ String locale, AppliedVariantsState appliedVariants) {
+ if ("$".equals(key)) {
+ return;
+ }
+ boolean shouldApplyVariant = appliedVariants.isShouldApplyVariant();
+ JSONObject applied = appliedVariants.getAppliedVariants();
+
+ String metaUid = metaUidFromValue(value);
+ String metaKeyPrefix = appliedVariants.getMetaKey().isEmpty() ? "" : appliedVariants.getMetaKey() + ".";
+ String updatedMetakey = shouldApplyVariant ? metaKeyPrefix + key : "";
+ if (!metaUid.isEmpty() && !updatedMetakey.isEmpty()) {
+ updatedMetakey = updatedMetakey + "." + metaUid;
+ }
+ // For array fields, per-element processing below must not overwrite this — line 220's field tag uses it.
+ String fieldMetakey = updatedMetakey;
+
+ if (value instanceof JSONArray) {
+ JSONArray arr = (JSONArray) value;
+ for (int index = 0; index < arr.length(); index++) {
+ Object obj = arr.opt(index);
+ if (obj == null || JSONObject.NULL.equals(obj)) {
+ continue;
+ }
+ String childKey = key + "__" + index;
+ String parentKey = key + "__parent";
+ metaUid = metaUidFromValue(obj);
+ String elementMetakey = shouldApplyVariant ? metaKeyPrefix + key : "";
+ if (!metaUid.isEmpty() && !elementMetakey.isEmpty()) {
+ elementMetakey = elementMetakey + "." + metaUid;
+ }
+ String indexPath = prefix + "." + key + "." + index;
+ String fieldPath = prefix + "." + key;
+ putTag(tags, childKey, indexPath, tagsAsObject, applied, shouldApplyVariant, elementMetakey);
+ putParentTag(tags, parentKey, fieldPath, tagsAsObject);
+ if (obj instanceof JSONObject) {
+ JSONObject jobj = (JSONObject) obj;
+ if (jobj.has("_content_type_uid") && jobj.has("uid")) {
+ JSONObject newApplied = jobj.optJSONObject("_applied_variants");
+ if (newApplied == null) {
+ JSONObject sys = jobj.optJSONObject("system");
+ if (sys != null) {
+ newApplied = sys.optJSONObject("applied_variants");
+ }
+ }
+ boolean newShould = newApplied != null;
+ String refLocale = jobj.has("locale") && !jobj.isNull("locale")
+ ? jobj.optString("locale", locale)
+ : locale;
+ String refPrefix = jobj.optString("_content_type_uid") + "." + jobj.optString("uid") + "."
+ + refLocale;
+ jobj.put("$", getTag(jobj, refPrefix, tagsAsObject, refLocale,
+ new AppliedVariantsState(newApplied, newShould, "")));
+ } else {
+ jobj.put("$", getTag(jobj, indexPath, tagsAsObject, locale,
+ new AppliedVariantsState(applied, shouldApplyVariant, elementMetakey)));
+ }
+ }
+ }
+ } else if (value instanceof JSONObject) {
+ JSONObject valueObj = (JSONObject) value;
+ valueObj.put("$", getTag(valueObj, prefix + "." + key, tagsAsObject, locale,
+ new AppliedVariantsState(applied, shouldApplyVariant, updatedMetakey)));
+ }
+
+ String fieldTagPath = prefix + "." + key;
+ putTag(tags, key, fieldTagPath, tagsAsObject, applied, shouldApplyVariant, fieldMetakey);
+ }
+
+ private static String metaUidFromValue(Object value) {
+ if (!(value instanceof JSONObject)) {
+ return "";
+ }
+ JSONObject jo = (JSONObject) value;
+ JSONObject meta = jo.optJSONObject("_metadata");
+ if (meta == null) {
+ return "";
+ }
+ return meta.optString("uid", "");
+ }
+
+ private static void putTag(JSONObject tags, String key, String dataValue, boolean tagsAsObject,
+ JSONObject appliedVariants, boolean shouldApplyVariant, String metaKey) {
+ TagsPayload payload = new TagsPayload(appliedVariants, shouldApplyVariant, metaKey);
+ if (tagsAsObject) {
+ tags.put(key, getTagsValueAsObject(dataValue, payload));
+ } else {
+ tags.put(key, getTagsValueAsString(dataValue, payload));
+ }
+ }
+
+ private static void putParentTag(JSONObject tags, String key, String dataValue, boolean tagsAsObject) {
+ if (tagsAsObject) {
+ tags.put(key, getParentTagsValueAsObject(dataValue));
+ } else {
+ tags.put(key, getParentTagsValueAsString(dataValue));
+ }
+ }
+
+ private static final class TagsPayload {
+ private final JSONObject appliedVariants;
+ private final boolean shouldApplyVariant;
+ private final String metaKey;
+
+ private TagsPayload(JSONObject appliedVariants, boolean shouldApplyVariant, String metaKey) {
+ this.appliedVariants = appliedVariants;
+ this.shouldApplyVariant = shouldApplyVariant;
+ this.metaKey = metaKey != null ? metaKey : "";
+ }
+ }
+
+ static String applyVariantToDataValue(String dataValue, JSONObject appliedVariants, boolean shouldApplyVariant,
+ String metaKey) {
+ if (shouldApplyVariant && appliedVariants != null) {
+ Object direct = appliedVariants.opt(metaKey);
+ if (direct != null && !JSONObject.NULL.equals(direct)) {
+ String variant = String.valueOf(direct);
+ String[] newDataValueArray = ("v2:" + dataValue).split("\\.", -1);
+ if (newDataValueArray.length > 1) {
+ newDataValueArray[1] = newDataValueArray[1] + "_" + variant;
+ return String.join(".", newDataValueArray);
+ }
+ }
+ String parentVariantisedPath = getParentVariantisedPath(appliedVariants, metaKey);
+ if (parentVariantisedPath != null && !parentVariantisedPath.isEmpty()) {
+ Object v = appliedVariants.opt(parentVariantisedPath);
+ if (v != null && !JSONObject.NULL.equals(v)) {
+ String variant = String.valueOf(v);
+ String[] newDataValueArray = ("v2:" + dataValue).split("\\.", -1);
+ if (newDataValueArray.length > 1) {
+ newDataValueArray[1] = newDataValueArray[1] + "_" + variant;
+ return String.join(".", newDataValueArray);
+ }
+ }
+ }
+ }
+ return dataValue;
+ }
+
+ static String getParentVariantisedPath(JSONObject appliedVariants, String metaKey) {
+ try {
+ if (appliedVariants == null) {
+ return "";
+ }
+ List variantisedFieldPaths = new ArrayList<>(appliedVariants.keySet());
+ variantisedFieldPaths.sort(Comparator.comparingInt(String::length).reversed());
+ String[] childPathFragments = metaKey.split("\\.", -1);
+ if (childPathFragments.length == 0 || variantisedFieldPaths.isEmpty()) {
+ return "";
+ }
+ for (String path : variantisedFieldPaths) {
+ String[] parentFragments = path.split("\\.", -1);
+ if (parentFragments.length > childPathFragments.length) {
+ continue;
+ }
+ boolean all = true;
+ for (int i = 0; i < parentFragments.length; i++) {
+ if (!Objects.equals(parentFragments[i], childPathFragments[i])) {
+ all = false;
+ break;
+ }
+ }
+ if (all) {
+ return path;
+ }
+ }
+ return "";
+ } catch (RuntimeException e) {
+ return "";
+ }
+ }
+
+ private static JSONObject getTagsValueAsObject(String dataValue, TagsPayload payload) {
+ String resolved = applyVariantToDataValue(dataValue, payload.appliedVariants, payload.shouldApplyVariant,
+ payload.metaKey);
+ JSONObject o = new JSONObject();
+ o.put("data-cslp", resolved);
+ return o;
+ }
+
+ private static String getTagsValueAsString(String dataValue, TagsPayload payload) {
+ String resolved = applyVariantToDataValue(dataValue, payload.appliedVariants, payload.shouldApplyVariant,
+ payload.metaKey);
+ return "data-cslp=" + resolved;
+ }
+
+ private static JSONObject getParentTagsValueAsObject(String dataValue) {
+ JSONObject o = new JSONObject();
+ o.put("data-cslp-parent-field", dataValue);
+ return o;
+ }
+
+ private static String getParentTagsValueAsString(String dataValue) {
+ return "data-cslp-parent-field=" + dataValue;
+ }
+}
diff --git a/src/main/java/com/contentstack/utils/EditableTagsOptions.java b/src/main/java/com/contentstack/utils/EditableTagsOptions.java
new file mode 100644
index 0000000..3055b39
--- /dev/null
+++ b/src/main/java/com/contentstack/utils/EditableTagsOptions.java
@@ -0,0 +1,30 @@
+package com.contentstack.utils;
+
+/**
+ * Options for {@link Utils#addEditableTags(org.json.JSONObject, String, boolean, String, EditableTagsOptions)}.
+ */
+public final class EditableTagsOptions {
+
+ private boolean useLowerCaseLocale = true;
+
+ public EditableTagsOptions() {
+ }
+
+ /**
+ * When {@code true} (default), the locale string is lowercased to match the JavaScript Utils default.
+ *
+ * @return whether locale is normalized to lowercase
+ */
+ public boolean isUseLowerCaseLocale() {
+ return useLowerCaseLocale;
+ }
+
+ /**
+ * @param useLowerCaseLocale if {@code true}, locale is lowercased; if {@code false}, locale is left as-is
+ * @return this instance for chaining
+ */
+ public EditableTagsOptions setUseLowerCaseLocale(boolean useLowerCaseLocale) {
+ this.useLowerCaseLocale = useLowerCaseLocale;
+ return this;
+ }
+}
diff --git a/src/main/java/com/contentstack/utils/Utils.java b/src/main/java/com/contentstack/utils/Utils.java
index 86ea789..6f4f810 100644
--- a/src/main/java/com/contentstack/utils/Utils.java
+++ b/src/main/java/com/contentstack/utils/Utils.java
@@ -399,6 +399,53 @@ public static JSONObject getDataCsvariantsAttribute(JSONArray entries, String co
return getVariantMetadataTags(entries, contentTypeUid);
}
+ /**
+ * Adds Contentstack Live Preview (CSLP) editable tags to an entry. Mutates {@code entry} by attaching a
+ * {@code $} object with {@code data-cslp} / {@code data-cslp-parent-field} values. Behavior matches
+ * contentstack-utils-javascript {@code addTags} / {@code entry-editable.ts}.
+ *
+ * @param entry CDA-style entry JSON; if {@code null}, the method returns without changes
+ * @param contentTypeUid content type UID (lower-cased internally)
+ * @param tagsAsObject if {@code true}, tag values are JSON objects; if {@code false}, {@code data-cslp=...} strings
+ * @param locale locale segment for paths (default in overloads: {@code en-us})
+ * @param options optional; default lowercases locale unless disabled
+ * @see EditableTags#addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)
+ */
+ public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale,
+ EditableTagsOptions options) {
+ EditableTags.addEditableTags(entry, contentTypeUid, tagsAsObject, locale, options);
+ }
+
+ /**
+ * @see #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)
+ */
+ public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject) {
+ EditableTags.addEditableTags(entry, contentTypeUid, tagsAsObject);
+ }
+
+ /**
+ * @see #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)
+ */
+ public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale) {
+ EditableTags.addEditableTags(entry, contentTypeUid, tagsAsObject, locale);
+ }
+
+ /**
+ * Alias for {@link #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)} (JS {@code addTags}).
+ */
+ public static void addTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale,
+ EditableTagsOptions options) {
+ EditableTags.addTags(entry, contentTypeUid, tagsAsObject, locale, options);
+ }
+
+ /**
+ * Recursive CSLP tag map for tests and advanced use — see {@link EditableTags#getTag(Object, String, boolean, String, EditableTags.AppliedVariantsState)}.
+ */
+ public static JSONObject getTag(Object content, String prefix, boolean tagsAsObject, String locale,
+ EditableTags.AppliedVariantsState appliedVariants) {
+ return EditableTags.getTag(content, prefix, tagsAsObject, locale, appliedVariants);
+ }
+
private static JSONArray extractVariantAliasesFromEntry(JSONObject entry) {
JSONArray variantArray = new JSONArray();
JSONObject publishDetails = entry.optJSONObject("publish_details");
diff --git a/src/test/java/com/contentstack/utils/EditableTagsTest.java b/src/test/java/com/contentstack/utils/EditableTagsTest.java
new file mode 100644
index 0000000..30a11a1
--- /dev/null
+++ b/src/test/java/com/contentstack/utils/EditableTagsTest.java
@@ -0,0 +1,219 @@
+package com.contentstack.utils;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Live Preview editable tags — parity with contentstack-utils-javascript {@code entry-editable.ts}.
+ */
+public class EditableTagsTest {
+
+ @Test
+ public void getTagReturnsEmptyForNullContent() {
+ JSONObject tags = Utils.getTag(null, "a.b.c", false, "en-us",
+ new EditableTags.AppliedVariantsState(null, false, ""));
+ Assert.assertNotNull(tags);
+ Assert.assertEquals(0, tags.length());
+ }
+
+ @Test
+ public void addEditableTagsPrimitivesAsStrings() {
+ JSONObject entry = new JSONObject();
+ entry.put("uid", "entry1");
+ entry.put("title", "Hello");
+ entry.put("count", 42);
+ Utils.addEditableTags(entry, "Blog", false, "en-us", null);
+ JSONObject dollar = entry.getJSONObject("$");
+ Assert.assertEquals("data-cslp=blog.entry1.en-us.title", dollar.getString("title"));
+ Assert.assertEquals("data-cslp=blog.entry1.en-us.count", dollar.getString("count"));
+ }
+
+ @Test
+ public void addEditableTagsPrimitivesAsObjects() {
+ JSONObject entry = new JSONObject();
+ entry.put("uid", "e1");
+ entry.put("title", "Hi");
+ Utils.addEditableTags(entry, "Post", true, "en-us", null);
+ JSONObject cslp = entry.getJSONObject("$").getJSONObject("title");
+ Assert.assertEquals("post.e1.en-us.title", cslp.getString("data-cslp"));
+ }
+
+ @Test
+ public void contentTypeUidLowercasedAndLocaleLowercasedByDefault() {
+ JSONObject entry = new JSONObject();
+ entry.put("uid", "u1");
+ entry.put("title", "x");
+ Utils.addEditableTags(entry, "LANDING", false, "EN-US", null);
+ Assert.assertTrue(entry.getJSONObject("$").getString("title").contains(".en-us."));
+ }
+
+ @Test
+ public void useLowerCaseLocaleFalsePreservesLocaleCasing() {
+ JSONObject entry = new JSONObject();
+ entry.put("uid", "u1");
+ entry.put("title", "x");
+ EditableTagsOptions opt = new EditableTagsOptions().setUseLowerCaseLocale(false);
+ Utils.addEditableTags(entry, "ct", false, "EN-US", opt);
+ Assert.assertTrue(entry.getJSONObject("$").getString("title").contains(".EN-US."));
+ }
+
+ @Test
+ public void nestedObjectGetsChildDollarMap() {
+ JSONObject inner = new JSONObject();
+ inner.put("name", "inner");
+ JSONObject entry = new JSONObject();
+ entry.put("uid", "e1");
+ entry.put("group", inner);
+ Utils.addEditableTags(entry, "ct", false, "en-us", null);
+ JSONObject groupDollar = inner.getJSONObject("$");
+ Assert.assertEquals("data-cslp=ct.e1.en-us.group.name", groupDollar.getString("name"));
+ Assert.assertEquals("data-cslp=ct.e1.en-us.group", entry.getJSONObject("$").getString("group"));
+ }
+
+ @Test
+ public void arrayFieldIndexAndParentTags() {
+ JSONObject entry = new JSONObject();
+ entry.put("uid", "e1");
+ entry.put("items", new JSONArray().put("a").put("b"));
+ Utils.addEditableTags(entry, "ct", false, "en-us", null);
+ JSONObject dollar = entry.getJSONObject("$");
+ Assert.assertEquals("data-cslp=ct.e1.en-us.items.0", dollar.getString("items__0"));
+ Assert.assertEquals("data-cslp=ct.e1.en-us.items.1", dollar.getString("items__1"));
+ Assert.assertEquals("data-cslp-parent-field=ct.e1.en-us.items", dollar.getString("items__parent"));
+ Assert.assertEquals("data-cslp=ct.e1.en-us.items", dollar.getString("items"));
+ }
+
+ /**
+ * Parent field tag for an array must use the field-level metakey (for variant resolution), not the last
+ * element's {@code _metadata.uid} suffix — otherwise a per-element variant key (e.g. {@code items.uidB})
+ * would incorrectly win for the parent {@code items} tag.
+ */
+ @Test
+ public void arrayFieldParentTagUsesFieldMetakeyNotLastElementMetadata() {
+ JSONObject applied = new JSONObject();
+ applied.put("items", "fieldVar");
+ applied.put("items.uidB", "wrongVar");
+ JSONObject metaA = new JSONObject();
+ metaA.put("uid", "uidA");
+ JSONObject metaB = new JSONObject();
+ metaB.put("uid", "uidB");
+ JSONObject el0 = new JSONObject();
+ el0.put("_metadata", metaA);
+ el0.put("x", "a");
+ JSONObject el1 = new JSONObject();
+ el1.put("_metadata", metaB);
+ el1.put("x", "b");
+ JSONArray arr = new JSONArray().put(el0).put(el1);
+ JSONObject entry = new JSONObject();
+ entry.put("uid", "e1");
+ entry.put("_applied_variants", applied);
+ entry.put("items", arr);
+ Utils.addEditableTags(entry, "ct", false, "en-us", null);
+ String parentItemsTag = entry.getJSONObject("$").getString("items");
+ Assert.assertTrue("parent field should resolve variant via key \"items\"",
+ parentItemsTag.contains("ct.e1_fieldVar.en-us.items"));
+ Assert.assertFalse("parent field must not apply last element's variant (items.uidB -> wrongVar)",
+ parentItemsTag.contains("e1_wrongVar"));
+ }
+
+ @Test
+ public void referenceInArrayUsesRefPrefix() {
+ JSONObject ref = new JSONObject();
+ ref.put("_content_type_uid", "author_ct");
+ ref.put("uid", "refuid");
+ ref.put("title", "Author");
+ JSONObject entry = new JSONObject();
+ entry.put("uid", "e1");
+ entry.put("authors", new JSONArray().put(ref));
+ Utils.addEditableTags(entry, "post", false, "en-us", null);
+ JSONObject refDollar = ref.getJSONObject("$");
+ Assert.assertEquals("data-cslp=author_ct.refuid.en-us.title", refDollar.getString("title"));
+ }
+
+ /**
+ * Referenced entry may declare its own {@code locale}; recursive {@code getTag} must receive {@code refLocale}
+ * (not the parent entry locale) so nested plain objects use the correct path segment, and nested refs in arrays
+ * without {@code locale} fall back to that reference locale (not the top-level entry locale).
+ */
+ @Test
+ public void referenceInArrayPassesRefLocaleToNestedGetTag() {
+ JSONObject nested = new JSONObject();
+ nested.put("name", "Nested");
+ JSONObject subRef = new JSONObject();
+ subRef.put("_content_type_uid", "child_ct");
+ subRef.put("uid", "c1");
+ subRef.put("x", "v");
+ JSONObject ref = new JSONObject();
+ ref.put("_content_type_uid", "author_ct");
+ ref.put("uid", "refuid");
+ ref.put("locale", "fr-fr");
+ ref.put("profile", nested);
+ ref.put("nested_refs", new JSONArray().put(subRef));
+ JSONObject entry = new JSONObject();
+ entry.put("uid", "e1");
+ entry.put("authors", new JSONArray().put(ref));
+ Utils.addEditableTags(entry, "post", false, "en-us", null);
+ Assert.assertEquals("data-cslp=author_ct.refuid.fr-fr.profile.name", nested.getJSONObject("$").getString("name"));
+ Assert.assertEquals("data-cslp=child_ct.c1.fr-fr.x", subRef.getJSONObject("$").getString("x"));
+ }
+
+ @Test
+ public void variantDirectFieldAppendsVariantToUidSegment() {
+ JSONObject applied = new JSONObject();
+ applied.put("title", "varA");
+ JSONObject entry = new JSONObject();
+ entry.put("uid", "eu1");
+ entry.put("_applied_variants", applied);
+ entry.put("title", "T");
+ Utils.addEditableTags(entry, "blog", false, "en-us", null);
+ String tag = entry.getJSONObject("$").getString("title");
+ Assert.assertTrue(tag.startsWith("data-cslp=v2:"));
+ Assert.assertTrue(tag.contains("blog.eu1_varA.en-us.title"));
+ }
+
+ @Test
+ public void appliedVariantsFromSystem() {
+ JSONObject applied = new JSONObject();
+ applied.put("field1", "v1");
+ JSONObject system = new JSONObject();
+ system.put("applied_variants", applied);
+ JSONObject entry = new JSONObject();
+ entry.put("uid", "u1");
+ entry.put("system", system);
+ entry.put("field1", "x");
+ Utils.addEditableTags(entry, "ct", false, "en-us", null);
+ String tag = entry.getJSONObject("$").getString("field1");
+ Assert.assertTrue(tag.contains("v2:"));
+ Assert.assertTrue(tag.contains("u1_v1"));
+ }
+
+ @Test
+ public void parentVariantisedPathInheritance() {
+ JSONObject applied = new JSONObject();
+ applied.put("parent", "pv");
+ JSONObject entry = new JSONObject();
+ entry.put("uid", "e1");
+ entry.put("_applied_variants", applied);
+ entry.put("parent", new JSONObject().put("child", "val"));
+ Utils.addEditableTags(entry, "ct", false, "en-us", null);
+ JSONObject parentObj = entry.getJSONObject("parent");
+ String childTag = parentObj.getJSONObject("$").getString("child");
+ Assert.assertTrue(childTag.startsWith("data-cslp=v2:"));
+ Assert.assertTrue(childTag.contains("e1_pv"));
+ }
+
+ @Test
+ public void addTagsAliasMatchesAddEditableTags() {
+ JSONObject a = new JSONObject();
+ a.put("uid", "1");
+ a.put("t", "x");
+ JSONObject b = new JSONObject();
+ b.put("uid", "1");
+ b.put("t", "x");
+ Utils.addEditableTags(a, "c", false, "en-us", null);
+ Utils.addTags(b, "c", false, "en-us", null);
+ Assert.assertEquals(a.getJSONObject("$").toString(), b.getJSONObject("$").toString());
+ }
+}