Skip to content

Commit 918c3bc

Browse files
Merge pull request #261 from contentstack/enh/dx-5408
Added variant utility
2 parents b827d20 + a36c992 commit 918c3bc

File tree

7 files changed

+385
-4
lines changed

7 files changed

+385
-4
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
## [1.9.0](https://github.com/contentstack/contentstack-utils-javascript/tree/v1.9.0)
4+
- Feat: Variant utilities `getVariantAliases` and `getVariantMetadataTags` to read variant alias strings from CDA entry `publish_details.variants` (requires fetches with the `x-cs-variant-uid` header set to aliases per [CDA variants](https://www.contentstack.com/docs/developers/apis/content-delivery-api#get-all-entry-variants)).
5+
36
## [1.8.0](https://github.com/contentstack/contentstack-utils-javascript/tree/v1.8.0)
47
- Fix: JSON-to-HTML now outputs valid HTML for nested lists when JSON RTE exports the nested list as a sibling of the preceding list item (`<li>`). The SDK folds such sibling `<ol>`/`<ul>` nodes into the previous `<li>` so the rendered HTML has the nested list inside the parent list item (PROD-2115).
58

__test__/mock/variant-fixtures.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/** CDA-style fixtures aligned with variant utility spec / Java Utils tests. */
2+
3+
export const variantEntrySingle = {
4+
uid: 'entry_uid_single',
5+
_metadata: {},
6+
locale: 'en-us',
7+
_version: 1,
8+
ACL: {},
9+
_in_progress: false,
10+
title: 'Sample Movie',
11+
created_at: '2025-11-20T10:00:00.000Z',
12+
updated_at: '2025-12-11T07:56:17.574Z',
13+
created_by: 'test_user',
14+
updated_by: 'test_user',
15+
publish_details: {
16+
time: '2025-12-11T07:56:17.574Z',
17+
user: 'test_user',
18+
environment: 'test_env',
19+
locale: 'en-us',
20+
variants: {
21+
cs_variant_0_0: {
22+
alias: 'cs_personalize_0_0',
23+
environment: 'test_env',
24+
time: '2025-12-11T07:56:17.574Z',
25+
locale: 'en-us',
26+
user: 'test_user',
27+
version: 1,
28+
},
29+
cs_variant_0_3: {
30+
alias: 'cs_personalize_0_3',
31+
environment: 'test_env',
32+
time: '2025-12-11T07:56:17.582Z',
33+
locale: 'en-us',
34+
user: 'test_user',
35+
version: 1,
36+
},
37+
},
38+
},
39+
} as Record<string, unknown>;
40+
41+
export const variantEntries = [
42+
{
43+
uid: 'entry_uid_1',
44+
_metadata: {},
45+
locale: 'en-us',
46+
_version: 1,
47+
title: 'Sample Movie',
48+
publish_details: {
49+
time: '2025-12-11T07:56:17.574Z',
50+
user: 'test_user',
51+
environment: 'test_env',
52+
locale: 'en-us',
53+
variants: {
54+
cs_variant_0_0: {
55+
alias: 'cs_personalize_0_0',
56+
environment: 'test_env',
57+
time: '2025-12-11T07:56:17.574Z',
58+
locale: 'en-us',
59+
user: 'test_user',
60+
version: 1,
61+
},
62+
cs_variant_0_3: {
63+
alias: 'cs_personalize_0_3',
64+
environment: 'test_env',
65+
time: '2025-12-11T07:56:17.582Z',
66+
locale: 'en-us',
67+
user: 'test_user',
68+
version: 1,
69+
},
70+
},
71+
},
72+
},
73+
{
74+
uid: 'entry_uid_2',
75+
_metadata: {},
76+
locale: 'en-us',
77+
_version: 2,
78+
title: 'Another Movie',
79+
publish_details: {
80+
time: '2025-12-11T07:10:19.964Z',
81+
user: 'test_user',
82+
environment: 'test_env',
83+
locale: 'en-us',
84+
variants: {
85+
cs_variant_0_0: {
86+
alias: 'cs_personalize_0_0',
87+
environment: 'test_env',
88+
time: '2025-12-11T07:10:19.964Z',
89+
locale: 'en-us',
90+
user: 'test_user',
91+
version: 2,
92+
},
93+
},
94+
},
95+
},
96+
{
97+
uid: 'entry_uid_3',
98+
_metadata: {},
99+
locale: 'en-us',
100+
_version: 1,
101+
title: 'Movie No Variants',
102+
publish_details: {
103+
time: '2025-11-20T10:00:00.000Z',
104+
user: 'test_user',
105+
environment: 'test_env',
106+
locale: 'en-us',
107+
},
108+
},
109+
] as Record<string, unknown>[];

__test__/variant-aliases.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { getVariantAliases, getVariantMetadataTags } from '../src/variant-aliases';
2+
import { variantEntrySingle, variantEntries } from './mock/variant-fixtures';
3+
4+
function sortAliases(aliases: string[]): string[] {
5+
return [...aliases].sort((a, b) => a.localeCompare(b));
6+
}
7+
8+
describe('getVariantAliases', () => {
9+
const contentTypeUid = 'movie';
10+
11+
it('extracts variant aliases for a single entry with explicit contentTypeUid', () => {
12+
const result = getVariantAliases(variantEntrySingle, contentTypeUid);
13+
expect(result.entry_uid).toBe('entry_uid_single');
14+
expect(result.contenttype_uid).toBe(contentTypeUid);
15+
expect(sortAliases(result.variants)).toEqual(sortAliases(['cs_personalize_0_0', 'cs_personalize_0_3']));
16+
});
17+
18+
it('uses _content_type_uid from entry when present', () => {
19+
const entry = {
20+
...variantEntrySingle,
21+
_content_type_uid: 'from_entry',
22+
};
23+
const result = getVariantAliases(entry, 'ignored');
24+
expect(result.contenttype_uid).toBe('from_entry');
25+
});
26+
27+
it('returns empty contenttype_uid when missing from entry and not passed', () => {
28+
const result = getVariantAliases(variantEntrySingle);
29+
expect(result.contenttype_uid).toBe('');
30+
});
31+
32+
it('maps multiple entries in order', () => {
33+
const results = getVariantAliases(variantEntries, contentTypeUid);
34+
expect(results).toHaveLength(3);
35+
expect(results[0].entry_uid).toBe('entry_uid_1');
36+
expect(sortAliases(results[0].variants)).toEqual(sortAliases(['cs_personalize_0_0', 'cs_personalize_0_3']));
37+
expect(results[1].entry_uid).toBe('entry_uid_2');
38+
expect(results[1].variants).toEqual(['cs_personalize_0_0']);
39+
expect(results[2].entry_uid).toBe('entry_uid_3');
40+
expect(results[2].variants).toEqual([]);
41+
});
42+
43+
it('returns empty variants when publish_details or variants is absent', () => {
44+
const entry = { uid: 'u1', _content_type_uid: 'ct' };
45+
expect(getVariantAliases(entry).variants).toEqual([]);
46+
const entry2 = { uid: 'u1', publish_details: {} };
47+
expect(getVariantAliases(entry2).variants).toEqual([]);
48+
const entry3 = { uid: 'u1', publish_details: { variants: {} } };
49+
expect(getVariantAliases(entry3).variants).toEqual([]);
50+
});
51+
52+
it('skips variant objects with missing or empty alias', () => {
53+
const entry = {
54+
uid: 'u1',
55+
publish_details: {
56+
variants: {
57+
a: { alias: 'keep_me' },
58+
b: { alias: '' },
59+
c: {},
60+
d: { alias: 'also_keep' },
61+
},
62+
},
63+
};
64+
const result = getVariantAliases(entry);
65+
expect(sortAliases(result.variants)).toEqual(sortAliases(['keep_me', 'also_keep']));
66+
});
67+
68+
it('skips variant entries that are null, non-objects, or arrays', () => {
69+
const variants: Record<string, unknown> = {
70+
skip_null: null,
71+
skip_string: 'not-an-object',
72+
skip_array: [1, 2],
73+
keep: { alias: 'only_valid' },
74+
};
75+
const entry = {
76+
uid: 'u1',
77+
publish_details: {
78+
variants,
79+
},
80+
};
81+
const result = getVariantAliases(entry);
82+
expect(result.variants).toEqual(['only_valid']);
83+
});
84+
85+
it('throws when entry is null or undefined', () => {
86+
expect(() => getVariantAliases(null as unknown as Record<string, unknown>)).toThrow();
87+
expect(() => getVariantAliases(undefined as unknown as Record<string, unknown>)).toThrow();
88+
});
89+
90+
it('throws TypeError when single entry is a non-object (e.g. primitive)', () => {
91+
expect(() => getVariantAliases(42 as unknown as Record<string, unknown>)).toThrow(TypeError);
92+
expect(() => getVariantAliases('entry' as unknown as Record<string, unknown>)).toThrow(TypeError);
93+
});
94+
95+
it('throws TypeError when an array item is not a plain object', () => {
96+
expect(() =>
97+
getVariantAliases([variantEntrySingle, [] as unknown as Record<string, unknown>])
98+
).toThrow(TypeError);
99+
});
100+
101+
it('throws when entry uid is missing or empty', () => {
102+
expect(() => getVariantAliases({})).toThrow(/uid/i);
103+
expect(() => getVariantAliases({ uid: '' })).toThrow(/uid/i);
104+
});
105+
106+
it('throws when entries array contains a non-object', () => {
107+
expect(() => getVariantAliases([variantEntrySingle, null as unknown as Record<string, unknown>])).toThrow();
108+
});
109+
});
110+
111+
describe('getVariantMetadataTags', () => {
112+
const contentTypeUid = 'movie';
113+
114+
it('serialises array results as JSON in data-csvariants', () => {
115+
const tag = getVariantMetadataTags(variantEntries, contentTypeUid);
116+
expect(tag).toHaveProperty('data-csvariants');
117+
const parsed = JSON.parse(tag['data-csvariants']) as Array<{
118+
entry_uid: string;
119+
contenttype_uid: string;
120+
variants: string[];
121+
}>;
122+
expect(parsed).toHaveLength(3);
123+
expect(parsed[0].entry_uid).toBe('entry_uid_1');
124+
expect(sortAliases(parsed[0].variants)).toEqual(sortAliases(['cs_personalize_0_0', 'cs_personalize_0_3']));
125+
});
126+
127+
it('returns empty JSON array string for empty entries', () => {
128+
const tag = getVariantMetadataTags([]);
129+
expect(tag['data-csvariants']).toBe('[]');
130+
});
131+
132+
it('throws when entries is null or not an array', () => {
133+
expect(() => getVariantMetadataTags(null as unknown as Record<string, unknown>[])).toThrow();
134+
expect(() => getVariantMetadataTags({} as unknown as Record<string, unknown>[])).toThrow();
135+
});
136+
});

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@contentstack/utils",
3-
"version": "1.8.0",
3+
"version": "1.9.0",
44
"description": "Contentstack utilities for Javascript",
55
"main": "dist/index.es.js",
66
"types": "dist/types/index.d.ts",

src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ export { jsonToHTML } from './json-to-html'
1414
export { GQL } from './gql'
1515
export { addTags as addEditableTags } from './entry-editable'
1616
export { updateAssetURLForGQL } from './updateAssetURLForGQL'
17-
export { getContentstackEndpoint, ContentstackEndpoints } from './endpoints'
17+
export { getContentstackEndpoint, ContentstackEndpoints } from './endpoints'
18+
export { getVariantAliases, getVariantMetadataTags } from './variant-aliases'
19+
export type { VariantAliasesResult, CDAEntryLike } from './variant-aliases'

0 commit comments

Comments
 (0)