Skip to content

Commit cf3034b

Browse files
Added variant utility
1 parent b827d20 commit cf3034b

File tree

7 files changed

+357
-4
lines changed

7 files changed

+357
-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: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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('throws when entry is null or undefined', () => {
69+
expect(() => getVariantAliases(null as unknown as Record<string, unknown>)).toThrow();
70+
expect(() => getVariantAliases(undefined as unknown as Record<string, unknown>)).toThrow();
71+
});
72+
73+
it('throws when entry uid is missing or empty', () => {
74+
expect(() => getVariantAliases({})).toThrow(/uid/i);
75+
expect(() => getVariantAliases({ uid: '' })).toThrow(/uid/i);
76+
});
77+
78+
it('throws when entries array contains a non-object', () => {
79+
expect(() => getVariantAliases([variantEntrySingle, null as unknown as Record<string, unknown>])).toThrow();
80+
});
81+
});
82+
83+
describe('getVariantMetadataTags', () => {
84+
const contentTypeUid = 'movie';
85+
86+
it('serialises array results as JSON in data-csvariants', () => {
87+
const tag = getVariantMetadataTags(variantEntries, contentTypeUid);
88+
expect(tag).toHaveProperty('data-csvariants');
89+
const parsed = JSON.parse(tag['data-csvariants']) as Array<{
90+
entry_uid: string;
91+
contenttype_uid: string;
92+
variants: string[];
93+
}>;
94+
expect(parsed).toHaveLength(3);
95+
expect(parsed[0].entry_uid).toBe('entry_uid_1');
96+
expect(sortAliases(parsed[0].variants)).toEqual(sortAliases(['cs_personalize_0_0', 'cs_personalize_0_3']));
97+
});
98+
99+
it('returns empty JSON array string for empty entries', () => {
100+
const tag = getVariantMetadataTags([]);
101+
expect(tag['data-csvariants']).toBe('[]');
102+
});
103+
104+
it('throws when entries is null or not an array', () => {
105+
expect(() => getVariantMetadataTags(null as unknown as Record<string, unknown>[])).toThrow();
106+
expect(() => getVariantMetadataTags({} as unknown as Record<string, unknown>[])).toThrow();
107+
});
108+
});

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'

src/variant-aliases.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Shape returned by {@link getVariantAliases} for interoperability with other Utils SDKs (snake_case JSON keys).
3+
*/
4+
export interface VariantAliasesResult {
5+
entry_uid: string;
6+
contenttype_uid: string;
7+
variants: string[];
8+
}
9+
10+
/** CDA entry JSON: at minimum includes `uid`; may include `_content_type_uid` and `publish_details.variants`. */
11+
export type CDAEntryLike = Record<string, unknown>;
12+
13+
function assertPlainObject(value: unknown, message: string): asserts value is Record<string, unknown> {
14+
if (value === null || value === undefined) {
15+
throw new TypeError(message);
16+
}
17+
if (typeof value !== 'object' || Array.isArray(value)) {
18+
throw new TypeError(message);
19+
}
20+
}
21+
22+
function requireEntryUid(entry: Record<string, unknown>): string {
23+
const uid = entry.uid;
24+
if (typeof uid !== 'string' || uid.length === 0) {
25+
throw new Error('Entry uid is required. The entry must include a non-empty uid string.');
26+
}
27+
return uid;
28+
}
29+
30+
function resolveContentTypeUid(entry: Record<string, unknown>, contentTypeUid?: string): string {
31+
const fromEntry = entry._content_type_uid;
32+
if (typeof fromEntry === 'string' && fromEntry.length > 0) {
33+
return fromEntry;
34+
}
35+
if (typeof contentTypeUid === 'string' && contentTypeUid.length > 0) {
36+
return contentTypeUid;
37+
}
38+
return '';
39+
}
40+
41+
function collectVariantAliases(entry: Record<string, unknown>): string[] {
42+
const publishDetails = entry.publish_details;
43+
if (!publishDetails || typeof publishDetails !== 'object' || Array.isArray(publishDetails)) {
44+
return [];
45+
}
46+
const variants = (publishDetails as Record<string, unknown>).variants;
47+
if (!variants || typeof variants !== 'object' || Array.isArray(variants)) {
48+
return [];
49+
}
50+
const out: string[] = [];
51+
const map = variants as Record<string, unknown>;
52+
for (const key of Object.keys(map)) {
53+
const v = map[key];
54+
if (!v || typeof v !== 'object' || Array.isArray(v)) {
55+
continue;
56+
}
57+
const alias = (v as { alias?: unknown }).alias;
58+
if (typeof alias === 'string' && alias.length > 0) {
59+
out.push(alias);
60+
}
61+
}
62+
return out;
63+
}
64+
65+
function mapEntryToResult(entry: Record<string, unknown>, contentTypeUid?: string): VariantAliasesResult {
66+
return {
67+
entry_uid: requireEntryUid(entry),
68+
contenttype_uid: resolveContentTypeUid(entry, contentTypeUid),
69+
variants: collectVariantAliases(entry),
70+
};
71+
}
72+
73+
/**
74+
* Extracts variant **alias** strings from `publish_details.variants` on a CDA entry.
75+
* Only present when the entry was fetched with the `x-cs-variant-uid` header set to variant **aliases** (not UIDs).
76+
*
77+
* @param entry - Single CDA entry object (must include `uid`).
78+
* @param contentTypeUid - Used when `entry._content_type_uid` is missing. Otherwise omitted or empty string yields `contenttype_uid: ""`.
79+
* @returns `{ entry_uid, contenttype_uid, variants }` with snake_case keys for cross-SDK JSON parity.
80+
* @throws TypeError if `entry` is null/undefined or not a plain object.
81+
* @throws Error if `entry` has no non-empty `uid`.
82+
*/
83+
export function getVariantAliases(entry: CDAEntryLike, contentTypeUid?: string): VariantAliasesResult;
84+
85+
/**
86+
* Extracts variant aliases for each entry in order.
87+
*
88+
* @param entries - Array of CDA entry objects.
89+
* @param contentTypeUid - Applied when an entry lacks `_content_type_uid`.
90+
* @returns One result object per input entry.
91+
* @throws TypeError if `entries` is null/undefined or not an array, or any element is not a plain object.
92+
* @throws Error if any entry has no non-empty `uid`.
93+
*/
94+
export function getVariantAliases(entries: CDAEntryLike[], contentTypeUid?: string): VariantAliasesResult[];
95+
96+
export function getVariantAliases(
97+
entryOrEntries: CDAEntryLike | CDAEntryLike[],
98+
contentTypeUid?: string
99+
): VariantAliasesResult | VariantAliasesResult[] {
100+
if (Array.isArray(entryOrEntries)) {
101+
return entryOrEntries.map((e) => {
102+
assertPlainObject(e, 'Each entry must be a plain object with a uid.');
103+
return mapEntryToResult(e, contentTypeUid);
104+
});
105+
}
106+
assertPlainObject(entryOrEntries, 'Entry is required. Provide a CDA entry object with a uid.');
107+
return mapEntryToResult(entryOrEntries, contentTypeUid);
108+
}
109+
110+
/**
111+
* Serialises variant alias results for use as an HTML `data-csvariants` attribute value.
112+
*
113+
* @param entries - CDA entries to process (same rules as {@link getVariantAliases} for each item).
114+
* @param contentTypeUid - Applied when an entry lacks `_content_type_uid`.
115+
* @returns `{ "data-csvariants": "<JSON string of VariantAliasesResult[]>" }`.
116+
* @throws TypeError if `entries` is null/undefined or not an array, or any element is not a plain object.
117+
* @throws Error if any entry has no non-empty `uid`.
118+
*/
119+
export function getVariantMetadataTags(
120+
entries: CDAEntryLike[],
121+
contentTypeUid?: string
122+
): { 'data-csvariants': string } {
123+
if (entries === null || entries === undefined) {
124+
throw new TypeError('Entries array is required. Provide an array of CDA entry objects.');
125+
}
126+
if (!Array.isArray(entries)) {
127+
throw new TypeError('Entries must be an array of CDA entry objects.');
128+
}
129+
const payload = getVariantAliases(entries, contentTypeUid);
130+
return { 'data-csvariants': JSON.stringify(payload) };
131+
}

0 commit comments

Comments
 (0)