Skip to content

Commit 583baf2

Browse files
Merge pull request #139 from contentstack/feat/DX-5740-editable-tags
Feat: Add Live Preview editable tags to entry JSON
2 parents 37981cf + e89b2a8 commit 583baf2

File tree

7 files changed

+658
-1
lines changed

7 files changed

+658
-1
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,9 @@ gradle-app.setting
260260

261261
sample/
262262

263+
# Local-only Spring Boot LP demo (not committed by default)
264+
sample-lp-demo/
265+
263266
# End of https://www.toptal.com/developers/gitignore/api/macos,code-java,java-web,maven,gradle,intellij,visualstudiocode,eclipse
264267
.idea/compiler.xml
265268
.idea/encodings.xml

Changelog.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
# Changelog
22

33
A brief description of what changes project contains
4+
5+
## Apr 20, 2026
6+
7+
#### v1.5.0
8+
9+
- Enhancement: Live Preview Editable tags
10+
411
## Mar 23, 2026
512

613
#### v1.4.0

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<modelVersion>4.0.0</modelVersion>
55
<groupId>com.contentstack.sdk</groupId>
66
<artifactId>utils</artifactId>
7-
<version>1.4.0</version>
7+
<version>1.5.0</version>
88
<packaging>jar</packaging>
99
<name>Contentstack-utils</name>
1010
<description>Java Utils SDK for Contentstack Content Delivery API, Contentstack is a headless CMS</description>
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
package com.contentstack.utils;
2+
3+
import org.json.JSONArray;
4+
import org.json.JSONObject;
5+
6+
import java.util.ArrayList;
7+
import java.util.Comparator;
8+
import java.util.Iterator;
9+
import java.util.List;
10+
import java.util.Objects;
11+
12+
/**
13+
* Live Preview editable tags (CSLP) — parity with contentstack-utils-javascript
14+
* {@code entry-editable.ts}.
15+
*/
16+
public final class EditableTags {
17+
18+
/**
19+
* Variant / meta-key state threaded through {@link #getTag(Object, String, boolean, String, AppliedVariantsState)}.
20+
*/
21+
public static final class AppliedVariantsState {
22+
private final JSONObject appliedVariants;
23+
private final boolean shouldApplyVariant;
24+
private final String metaKey;
25+
26+
public AppliedVariantsState(JSONObject appliedVariants, boolean shouldApplyVariant, String metaKey) {
27+
this.appliedVariants = appliedVariants;
28+
this.shouldApplyVariant = shouldApplyVariant;
29+
this.metaKey = metaKey != null ? metaKey : "";
30+
}
31+
32+
public JSONObject getAppliedVariants() {
33+
return appliedVariants;
34+
}
35+
36+
public boolean isShouldApplyVariant() {
37+
return shouldApplyVariant;
38+
}
39+
40+
public String getMetaKey() {
41+
return metaKey;
42+
}
43+
}
44+
45+
private EditableTags() {
46+
}
47+
48+
/**
49+
* Adds Contentstack Live Preview (CSLP) data tags to an entry for editable UIs.
50+
* Mutates the entry by attaching a {@code $} property with tag strings or objects
51+
* ({@code data-cslp} / {@code data-cslp-parent-field}) for each field.
52+
*
53+
* @param entry CDA-style entry JSON (must not be {@code null}); must contain {@code uid}
54+
* @param contentTypeUid content type UID (e.g. {@code blog_post})
55+
* @param tagsAsObject if {@code true}, tags are JSON objects; if {@code false}, {@code data-cslp=...} strings
56+
* @param locale locale code (default in overloads: {@code en-us})
57+
* @param options optional; controls locale casing (default lowercases locale)
58+
*/
59+
public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale,
60+
EditableTagsOptions options) {
61+
if (entry == null) {
62+
return;
63+
}
64+
boolean useLowerCaseLocale = true;
65+
if (options != null) {
66+
useLowerCaseLocale = options.isUseLowerCaseLocale();
67+
}
68+
String ct = contentTypeUid == null ? "" : contentTypeUid.toLowerCase();
69+
String loc = locale == null ? "en-us" : locale;
70+
if (useLowerCaseLocale) {
71+
loc = loc.toLowerCase();
72+
}
73+
JSONObject applied = entry.optJSONObject("_applied_variants");
74+
if (applied == null) {
75+
JSONObject system = entry.optJSONObject("system");
76+
if (system != null) {
77+
applied = system.optJSONObject("applied_variants");
78+
}
79+
}
80+
boolean shouldApply = applied != null;
81+
String uid = entry.optString("uid", "");
82+
String prefix = ct + "." + uid + "." + loc;
83+
AppliedVariantsState state = new AppliedVariantsState(applied, shouldApply, "");
84+
entry.put("$", getTag(entry, prefix, tagsAsObject, loc, state));
85+
}
86+
87+
/**
88+
* @see #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)
89+
*/
90+
public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject) {
91+
addEditableTags(entry, contentTypeUid, tagsAsObject, "en-us", null);
92+
}
93+
94+
/**
95+
* @see #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)
96+
*/
97+
public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale) {
98+
addEditableTags(entry, contentTypeUid, tagsAsObject, locale, null);
99+
}
100+
101+
/**
102+
* Alias for {@link #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)} — matches JS
103+
* {@code addTags}.
104+
*/
105+
public static void addTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale,
106+
EditableTagsOptions options) {
107+
addEditableTags(entry, contentTypeUid, tagsAsObject, locale, options);
108+
}
109+
110+
/**
111+
* Recursive tag map for the given content (entry object or array). Exposed for parity with JS tests.
112+
*
113+
* @param content {@link JSONObject}, {@link JSONArray}, or null
114+
* @param prefix path prefix ({@code contentTypeUid.entryUid.locale...})
115+
* @param tagsAsObject string vs object tag form
116+
* @param locale locale for reference entries
117+
* @param appliedVariants variant state
118+
* @return map of field keys to tag string or tag object
119+
*/
120+
public static JSONObject getTag(Object content, String prefix, boolean tagsAsObject, String locale,
121+
AppliedVariantsState appliedVariants) {
122+
if (content == null || JSONObject.NULL.equals(content)) {
123+
return new JSONObject();
124+
}
125+
if (content instanceof JSONArray) {
126+
return getTagForArray((JSONArray) content, prefix, tagsAsObject, locale, appliedVariants);
127+
}
128+
if (content instanceof JSONObject) {
129+
return getTagForJSONObject((JSONObject) content, prefix, tagsAsObject, locale, appliedVariants);
130+
}
131+
return new JSONObject();
132+
}
133+
134+
private static JSONObject getTagForJSONObject(JSONObject content, String prefix, boolean tagsAsObject,
135+
String locale, AppliedVariantsState appliedVariants) {
136+
JSONObject tags = new JSONObject();
137+
Iterator<String> keys = content.keys();
138+
while (keys.hasNext()) {
139+
String key = keys.next();
140+
handleKey(tags, key, content.opt(key), prefix, tagsAsObject, locale, appliedVariants);
141+
}
142+
return tags;
143+
}
144+
145+
private static JSONObject getTagForArray(JSONArray content, String prefix, boolean tagsAsObject, String locale,
146+
AppliedVariantsState appliedVariants) {
147+
JSONObject tags = new JSONObject();
148+
for (int i = 0; i < content.length(); i++) {
149+
String key = Integer.toString(i);
150+
handleKey(tags, key, content.opt(i), prefix, tagsAsObject, locale, appliedVariants);
151+
}
152+
return tags;
153+
}
154+
155+
/** One entry from {@code Object.entries} — same structure for {@link JSONObject} and {@link JSONArray}. */
156+
private static void handleKey(JSONObject tags, String key, Object value, String prefix, boolean tagsAsObject,
157+
String locale, AppliedVariantsState appliedVariants) {
158+
if ("$".equals(key)) {
159+
return;
160+
}
161+
boolean shouldApplyVariant = appliedVariants.isShouldApplyVariant();
162+
JSONObject applied = appliedVariants.getAppliedVariants();
163+
164+
String metaUid = metaUidFromValue(value);
165+
String metaKeyPrefix = appliedVariants.getMetaKey().isEmpty() ? "" : appliedVariants.getMetaKey() + ".";
166+
String updatedMetakey = shouldApplyVariant ? metaKeyPrefix + key : "";
167+
if (!metaUid.isEmpty() && !updatedMetakey.isEmpty()) {
168+
updatedMetakey = updatedMetakey + "." + metaUid;
169+
}
170+
// For array fields, per-element processing below must not overwrite this — line 220's field tag uses it.
171+
String fieldMetakey = updatedMetakey;
172+
173+
if (value instanceof JSONArray) {
174+
JSONArray arr = (JSONArray) value;
175+
for (int index = 0; index < arr.length(); index++) {
176+
Object obj = arr.opt(index);
177+
if (obj == null || JSONObject.NULL.equals(obj)) {
178+
continue;
179+
}
180+
String childKey = key + "__" + index;
181+
String parentKey = key + "__parent";
182+
metaUid = metaUidFromValue(obj);
183+
String elementMetakey = shouldApplyVariant ? metaKeyPrefix + key : "";
184+
if (!metaUid.isEmpty() && !elementMetakey.isEmpty()) {
185+
elementMetakey = elementMetakey + "." + metaUid;
186+
}
187+
String indexPath = prefix + "." + key + "." + index;
188+
String fieldPath = prefix + "." + key;
189+
putTag(tags, childKey, indexPath, tagsAsObject, applied, shouldApplyVariant, elementMetakey);
190+
putParentTag(tags, parentKey, fieldPath, tagsAsObject);
191+
if (obj instanceof JSONObject) {
192+
JSONObject jobj = (JSONObject) obj;
193+
if (jobj.has("_content_type_uid") && jobj.has("uid")) {
194+
JSONObject newApplied = jobj.optJSONObject("_applied_variants");
195+
if (newApplied == null) {
196+
JSONObject sys = jobj.optJSONObject("system");
197+
if (sys != null) {
198+
newApplied = sys.optJSONObject("applied_variants");
199+
}
200+
}
201+
boolean newShould = newApplied != null;
202+
String refLocale = jobj.has("locale") && !jobj.isNull("locale")
203+
? jobj.optString("locale", locale)
204+
: locale;
205+
String refPrefix = jobj.optString("_content_type_uid") + "." + jobj.optString("uid") + "."
206+
+ refLocale;
207+
jobj.put("$", getTag(jobj, refPrefix, tagsAsObject, refLocale,
208+
new AppliedVariantsState(newApplied, newShould, "")));
209+
} else {
210+
jobj.put("$", getTag(jobj, indexPath, tagsAsObject, locale,
211+
new AppliedVariantsState(applied, shouldApplyVariant, elementMetakey)));
212+
}
213+
}
214+
}
215+
} else if (value instanceof JSONObject) {
216+
JSONObject valueObj = (JSONObject) value;
217+
valueObj.put("$", getTag(valueObj, prefix + "." + key, tagsAsObject, locale,
218+
new AppliedVariantsState(applied, shouldApplyVariant, updatedMetakey)));
219+
}
220+
221+
String fieldTagPath = prefix + "." + key;
222+
putTag(tags, key, fieldTagPath, tagsAsObject, applied, shouldApplyVariant, fieldMetakey);
223+
}
224+
225+
private static String metaUidFromValue(Object value) {
226+
if (!(value instanceof JSONObject)) {
227+
return "";
228+
}
229+
JSONObject jo = (JSONObject) value;
230+
JSONObject meta = jo.optJSONObject("_metadata");
231+
if (meta == null) {
232+
return "";
233+
}
234+
return meta.optString("uid", "");
235+
}
236+
237+
private static void putTag(JSONObject tags, String key, String dataValue, boolean tagsAsObject,
238+
JSONObject appliedVariants, boolean shouldApplyVariant, String metaKey) {
239+
TagsPayload payload = new TagsPayload(appliedVariants, shouldApplyVariant, metaKey);
240+
if (tagsAsObject) {
241+
tags.put(key, getTagsValueAsObject(dataValue, payload));
242+
} else {
243+
tags.put(key, getTagsValueAsString(dataValue, payload));
244+
}
245+
}
246+
247+
private static void putParentTag(JSONObject tags, String key, String dataValue, boolean tagsAsObject) {
248+
if (tagsAsObject) {
249+
tags.put(key, getParentTagsValueAsObject(dataValue));
250+
} else {
251+
tags.put(key, getParentTagsValueAsString(dataValue));
252+
}
253+
}
254+
255+
private static final class TagsPayload {
256+
private final JSONObject appliedVariants;
257+
private final boolean shouldApplyVariant;
258+
private final String metaKey;
259+
260+
private TagsPayload(JSONObject appliedVariants, boolean shouldApplyVariant, String metaKey) {
261+
this.appliedVariants = appliedVariants;
262+
this.shouldApplyVariant = shouldApplyVariant;
263+
this.metaKey = metaKey != null ? metaKey : "";
264+
}
265+
}
266+
267+
static String applyVariantToDataValue(String dataValue, JSONObject appliedVariants, boolean shouldApplyVariant,
268+
String metaKey) {
269+
if (shouldApplyVariant && appliedVariants != null) {
270+
Object direct = appliedVariants.opt(metaKey);
271+
if (direct != null && !JSONObject.NULL.equals(direct)) {
272+
String variant = String.valueOf(direct);
273+
String[] newDataValueArray = ("v2:" + dataValue).split("\\.", -1);
274+
if (newDataValueArray.length > 1) {
275+
newDataValueArray[1] = newDataValueArray[1] + "_" + variant;
276+
return String.join(".", newDataValueArray);
277+
}
278+
}
279+
String parentVariantisedPath = getParentVariantisedPath(appliedVariants, metaKey);
280+
if (parentVariantisedPath != null && !parentVariantisedPath.isEmpty()) {
281+
Object v = appliedVariants.opt(parentVariantisedPath);
282+
if (v != null && !JSONObject.NULL.equals(v)) {
283+
String variant = String.valueOf(v);
284+
String[] newDataValueArray = ("v2:" + dataValue).split("\\.", -1);
285+
if (newDataValueArray.length > 1) {
286+
newDataValueArray[1] = newDataValueArray[1] + "_" + variant;
287+
return String.join(".", newDataValueArray);
288+
}
289+
}
290+
}
291+
}
292+
return dataValue;
293+
}
294+
295+
static String getParentVariantisedPath(JSONObject appliedVariants, String metaKey) {
296+
try {
297+
if (appliedVariants == null) {
298+
return "";
299+
}
300+
List<String> variantisedFieldPaths = new ArrayList<>(appliedVariants.keySet());
301+
variantisedFieldPaths.sort(Comparator.comparingInt(String::length).reversed());
302+
String[] childPathFragments = metaKey.split("\\.", -1);
303+
if (childPathFragments.length == 0 || variantisedFieldPaths.isEmpty()) {
304+
return "";
305+
}
306+
for (String path : variantisedFieldPaths) {
307+
String[] parentFragments = path.split("\\.", -1);
308+
if (parentFragments.length > childPathFragments.length) {
309+
continue;
310+
}
311+
boolean all = true;
312+
for (int i = 0; i < parentFragments.length; i++) {
313+
if (!Objects.equals(parentFragments[i], childPathFragments[i])) {
314+
all = false;
315+
break;
316+
}
317+
}
318+
if (all) {
319+
return path;
320+
}
321+
}
322+
return "";
323+
} catch (RuntimeException e) {
324+
return "";
325+
}
326+
}
327+
328+
private static JSONObject getTagsValueAsObject(String dataValue, TagsPayload payload) {
329+
String resolved = applyVariantToDataValue(dataValue, payload.appliedVariants, payload.shouldApplyVariant,
330+
payload.metaKey);
331+
JSONObject o = new JSONObject();
332+
o.put("data-cslp", resolved);
333+
return o;
334+
}
335+
336+
private static String getTagsValueAsString(String dataValue, TagsPayload payload) {
337+
String resolved = applyVariantToDataValue(dataValue, payload.appliedVariants, payload.shouldApplyVariant,
338+
payload.metaKey);
339+
return "data-cslp=" + resolved;
340+
}
341+
342+
private static JSONObject getParentTagsValueAsObject(String dataValue) {
343+
JSONObject o = new JSONObject();
344+
o.put("data-cslp-parent-field", dataValue);
345+
return o;
346+
}
347+
348+
private static String getParentTagsValueAsString(String dataValue) {
349+
return "data-cslp-parent-field=" + dataValue;
350+
}
351+
}

0 commit comments

Comments
 (0)