From cf3034b66df950132957f696861ac0b6fc8dbe42 Mon Sep 17 00:00:00 2001
From: sunil-lakshman <104969541+sunil-lakshman@users.noreply.github.com>
Date: Mon, 23 Mar 2026 12:29:38 +0530
Subject: [PATCH 1/2] Added variant utility
---
CHANGELOG.md | 3 +
__test__/mock/variant-fixtures.ts | 109 +++++++++++++++++++++++++
__test__/variant-aliases.test.ts | 108 ++++++++++++++++++++++++
package-lock.json | 4 +-
package.json | 2 +-
src/index.ts | 4 +-
src/variant-aliases.ts | 131 ++++++++++++++++++++++++++++++
7 files changed, 357 insertions(+), 4 deletions(-)
create mode 100644 __test__/mock/variant-fixtures.ts
create mode 100644 __test__/variant-aliases.test.ts
create mode 100644 src/variant-aliases.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0db76f5..4121676 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## [1.9.0](https://github.com/contentstack/contentstack-utils-javascript/tree/v1.9.0)
+- 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)).
+
## [1.8.0](https://github.com/contentstack/contentstack-utils-javascript/tree/v1.8.0)
- 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 (`
`). The SDK folds such sibling ``/`` nodes into the previous `- ` so the rendered HTML has the nested list inside the parent list item (PROD-2115).
diff --git a/__test__/mock/variant-fixtures.ts b/__test__/mock/variant-fixtures.ts
new file mode 100644
index 0000000..d958892
--- /dev/null
+++ b/__test__/mock/variant-fixtures.ts
@@ -0,0 +1,109 @@
+/** CDA-style fixtures aligned with variant utility spec / Java Utils tests. */
+
+export const variantEntrySingle = {
+ uid: 'entry_uid_single',
+ _metadata: {},
+ locale: 'en-us',
+ _version: 1,
+ ACL: {},
+ _in_progress: false,
+ title: 'Sample Movie',
+ created_at: '2025-11-20T10:00:00.000Z',
+ updated_at: '2025-12-11T07:56:17.574Z',
+ created_by: 'test_user',
+ updated_by: 'test_user',
+ publish_details: {
+ time: '2025-12-11T07:56:17.574Z',
+ user: 'test_user',
+ environment: 'test_env',
+ locale: 'en-us',
+ variants: {
+ cs_variant_0_0: {
+ alias: 'cs_personalize_0_0',
+ environment: 'test_env',
+ time: '2025-12-11T07:56:17.574Z',
+ locale: 'en-us',
+ user: 'test_user',
+ version: 1,
+ },
+ cs_variant_0_3: {
+ alias: 'cs_personalize_0_3',
+ environment: 'test_env',
+ time: '2025-12-11T07:56:17.582Z',
+ locale: 'en-us',
+ user: 'test_user',
+ version: 1,
+ },
+ },
+ },
+} as Record;
+
+export const variantEntries = [
+ {
+ uid: 'entry_uid_1',
+ _metadata: {},
+ locale: 'en-us',
+ _version: 1,
+ title: 'Sample Movie',
+ publish_details: {
+ time: '2025-12-11T07:56:17.574Z',
+ user: 'test_user',
+ environment: 'test_env',
+ locale: 'en-us',
+ variants: {
+ cs_variant_0_0: {
+ alias: 'cs_personalize_0_0',
+ environment: 'test_env',
+ time: '2025-12-11T07:56:17.574Z',
+ locale: 'en-us',
+ user: 'test_user',
+ version: 1,
+ },
+ cs_variant_0_3: {
+ alias: 'cs_personalize_0_3',
+ environment: 'test_env',
+ time: '2025-12-11T07:56:17.582Z',
+ locale: 'en-us',
+ user: 'test_user',
+ version: 1,
+ },
+ },
+ },
+ },
+ {
+ uid: 'entry_uid_2',
+ _metadata: {},
+ locale: 'en-us',
+ _version: 2,
+ title: 'Another Movie',
+ publish_details: {
+ time: '2025-12-11T07:10:19.964Z',
+ user: 'test_user',
+ environment: 'test_env',
+ locale: 'en-us',
+ variants: {
+ cs_variant_0_0: {
+ alias: 'cs_personalize_0_0',
+ environment: 'test_env',
+ time: '2025-12-11T07:10:19.964Z',
+ locale: 'en-us',
+ user: 'test_user',
+ version: 2,
+ },
+ },
+ },
+ },
+ {
+ uid: 'entry_uid_3',
+ _metadata: {},
+ locale: 'en-us',
+ _version: 1,
+ title: 'Movie No Variants',
+ publish_details: {
+ time: '2025-11-20T10:00:00.000Z',
+ user: 'test_user',
+ environment: 'test_env',
+ locale: 'en-us',
+ },
+ },
+] as Record[];
diff --git a/__test__/variant-aliases.test.ts b/__test__/variant-aliases.test.ts
new file mode 100644
index 0000000..f9814b3
--- /dev/null
+++ b/__test__/variant-aliases.test.ts
@@ -0,0 +1,108 @@
+import { getVariantAliases, getVariantMetadataTags } from '../src/variant-aliases';
+import { variantEntrySingle, variantEntries } from './mock/variant-fixtures';
+
+function sortAliases(aliases: string[]): string[] {
+ return [...aliases].sort((a, b) => a.localeCompare(b));
+}
+
+describe('getVariantAliases', () => {
+ const contentTypeUid = 'movie';
+
+ it('extracts variant aliases for a single entry with explicit contentTypeUid', () => {
+ const result = getVariantAliases(variantEntrySingle, contentTypeUid);
+ expect(result.entry_uid).toBe('entry_uid_single');
+ expect(result.contenttype_uid).toBe(contentTypeUid);
+ expect(sortAliases(result.variants)).toEqual(sortAliases(['cs_personalize_0_0', 'cs_personalize_0_3']));
+ });
+
+ it('uses _content_type_uid from entry when present', () => {
+ const entry = {
+ ...variantEntrySingle,
+ _content_type_uid: 'from_entry',
+ };
+ const result = getVariantAliases(entry, 'ignored');
+ expect(result.contenttype_uid).toBe('from_entry');
+ });
+
+ it('returns empty contenttype_uid when missing from entry and not passed', () => {
+ const result = getVariantAliases(variantEntrySingle);
+ expect(result.contenttype_uid).toBe('');
+ });
+
+ it('maps multiple entries in order', () => {
+ const results = getVariantAliases(variantEntries, contentTypeUid);
+ expect(results).toHaveLength(3);
+ expect(results[0].entry_uid).toBe('entry_uid_1');
+ expect(sortAliases(results[0].variants)).toEqual(sortAliases(['cs_personalize_0_0', 'cs_personalize_0_3']));
+ expect(results[1].entry_uid).toBe('entry_uid_2');
+ expect(results[1].variants).toEqual(['cs_personalize_0_0']);
+ expect(results[2].entry_uid).toBe('entry_uid_3');
+ expect(results[2].variants).toEqual([]);
+ });
+
+ it('returns empty variants when publish_details or variants is absent', () => {
+ const entry = { uid: 'u1', _content_type_uid: 'ct' };
+ expect(getVariantAliases(entry).variants).toEqual([]);
+ const entry2 = { uid: 'u1', publish_details: {} };
+ expect(getVariantAliases(entry2).variants).toEqual([]);
+ const entry3 = { uid: 'u1', publish_details: { variants: {} } };
+ expect(getVariantAliases(entry3).variants).toEqual([]);
+ });
+
+ it('skips variant objects with missing or empty alias', () => {
+ const entry = {
+ uid: 'u1',
+ publish_details: {
+ variants: {
+ a: { alias: 'keep_me' },
+ b: { alias: '' },
+ c: {},
+ d: { alias: 'also_keep' },
+ },
+ },
+ };
+ const result = getVariantAliases(entry);
+ expect(sortAliases(result.variants)).toEqual(sortAliases(['keep_me', 'also_keep']));
+ });
+
+ it('throws when entry is null or undefined', () => {
+ expect(() => getVariantAliases(null as unknown as Record)).toThrow();
+ expect(() => getVariantAliases(undefined as unknown as Record)).toThrow();
+ });
+
+ it('throws when entry uid is missing or empty', () => {
+ expect(() => getVariantAliases({})).toThrow(/uid/i);
+ expect(() => getVariantAliases({ uid: '' })).toThrow(/uid/i);
+ });
+
+ it('throws when entries array contains a non-object', () => {
+ expect(() => getVariantAliases([variantEntrySingle, null as unknown as Record])).toThrow();
+ });
+});
+
+describe('getVariantMetadataTags', () => {
+ const contentTypeUid = 'movie';
+
+ it('serialises array results as JSON in data-csvariants', () => {
+ const tag = getVariantMetadataTags(variantEntries, contentTypeUid);
+ expect(tag).toHaveProperty('data-csvariants');
+ const parsed = JSON.parse(tag['data-csvariants']) as Array<{
+ entry_uid: string;
+ contenttype_uid: string;
+ variants: string[];
+ }>;
+ expect(parsed).toHaveLength(3);
+ expect(parsed[0].entry_uid).toBe('entry_uid_1');
+ expect(sortAliases(parsed[0].variants)).toEqual(sortAliases(['cs_personalize_0_0', 'cs_personalize_0_3']));
+ });
+
+ it('returns empty JSON array string for empty entries', () => {
+ const tag = getVariantMetadataTags([]);
+ expect(tag['data-csvariants']).toBe('[]');
+ });
+
+ it('throws when entries is null or not an array', () => {
+ expect(() => getVariantMetadataTags(null as unknown as Record[])).toThrow();
+ expect(() => getVariantMetadataTags({} as unknown as Record[])).toThrow();
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index 15bb0d2..bd143df 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@contentstack/utils",
- "version": "1.8.0",
+ "version": "1.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@contentstack/utils",
- "version": "1.8.0",
+ "version": "1.9.0",
"license": "MIT",
"devDependencies": {
"@commitlint/cli": "^17.8.1",
diff --git a/package.json b/package.json
index a933c38..a6c6afb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@contentstack/utils",
- "version": "1.8.0",
+ "version": "1.9.0",
"description": "Contentstack utilities for Javascript",
"main": "dist/index.es.js",
"types": "dist/types/index.d.ts",
diff --git a/src/index.ts b/src/index.ts
index 8d0d7c3..55a0ed6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -14,4 +14,6 @@ export { jsonToHTML } from './json-to-html'
export { GQL } from './gql'
export { addTags as addEditableTags } from './entry-editable'
export { updateAssetURLForGQL } from './updateAssetURLForGQL'
-export { getContentstackEndpoint, ContentstackEndpoints } from './endpoints'
\ No newline at end of file
+export { getContentstackEndpoint, ContentstackEndpoints } from './endpoints'
+export { getVariantAliases, getVariantMetadataTags } from './variant-aliases'
+export type { VariantAliasesResult, CDAEntryLike } from './variant-aliases'
\ No newline at end of file
diff --git a/src/variant-aliases.ts b/src/variant-aliases.ts
new file mode 100644
index 0000000..78e40ac
--- /dev/null
+++ b/src/variant-aliases.ts
@@ -0,0 +1,131 @@
+/**
+ * Shape returned by {@link getVariantAliases} for interoperability with other Utils SDKs (snake_case JSON keys).
+ */
+export interface VariantAliasesResult {
+ entry_uid: string;
+ contenttype_uid: string;
+ variants: string[];
+}
+
+/** CDA entry JSON: at minimum includes `uid`; may include `_content_type_uid` and `publish_details.variants`. */
+export type CDAEntryLike = Record;
+
+function assertPlainObject(value: unknown, message: string): asserts value is Record {
+ if (value === null || value === undefined) {
+ throw new TypeError(message);
+ }
+ if (typeof value !== 'object' || Array.isArray(value)) {
+ throw new TypeError(message);
+ }
+}
+
+function requireEntryUid(entry: Record): string {
+ const uid = entry.uid;
+ if (typeof uid !== 'string' || uid.length === 0) {
+ throw new Error('Entry uid is required. The entry must include a non-empty uid string.');
+ }
+ return uid;
+}
+
+function resolveContentTypeUid(entry: Record, contentTypeUid?: string): string {
+ const fromEntry = entry._content_type_uid;
+ if (typeof fromEntry === 'string' && fromEntry.length > 0) {
+ return fromEntry;
+ }
+ if (typeof contentTypeUid === 'string' && contentTypeUid.length > 0) {
+ return contentTypeUid;
+ }
+ return '';
+}
+
+function collectVariantAliases(entry: Record): string[] {
+ const publishDetails = entry.publish_details;
+ if (!publishDetails || typeof publishDetails !== 'object' || Array.isArray(publishDetails)) {
+ return [];
+ }
+ const variants = (publishDetails as Record).variants;
+ if (!variants || typeof variants !== 'object' || Array.isArray(variants)) {
+ return [];
+ }
+ const out: string[] = [];
+ const map = variants as Record;
+ for (const key of Object.keys(map)) {
+ const v = map[key];
+ if (!v || typeof v !== 'object' || Array.isArray(v)) {
+ continue;
+ }
+ const alias = (v as { alias?: unknown }).alias;
+ if (typeof alias === 'string' && alias.length > 0) {
+ out.push(alias);
+ }
+ }
+ return out;
+}
+
+function mapEntryToResult(entry: Record, contentTypeUid?: string): VariantAliasesResult {
+ return {
+ entry_uid: requireEntryUid(entry),
+ contenttype_uid: resolveContentTypeUid(entry, contentTypeUid),
+ variants: collectVariantAliases(entry),
+ };
+}
+
+/**
+ * Extracts variant **alias** strings from `publish_details.variants` on a CDA entry.
+ * Only present when the entry was fetched with the `x-cs-variant-uid` header set to variant **aliases** (not UIDs).
+ *
+ * @param entry - Single CDA entry object (must include `uid`).
+ * @param contentTypeUid - Used when `entry._content_type_uid` is missing. Otherwise omitted or empty string yields `contenttype_uid: ""`.
+ * @returns `{ entry_uid, contenttype_uid, variants }` with snake_case keys for cross-SDK JSON parity.
+ * @throws TypeError if `entry` is null/undefined or not a plain object.
+ * @throws Error if `entry` has no non-empty `uid`.
+ */
+export function getVariantAliases(entry: CDAEntryLike, contentTypeUid?: string): VariantAliasesResult;
+
+/**
+ * Extracts variant aliases for each entry in order.
+ *
+ * @param entries - Array of CDA entry objects.
+ * @param contentTypeUid - Applied when an entry lacks `_content_type_uid`.
+ * @returns One result object per input entry.
+ * @throws TypeError if `entries` is null/undefined or not an array, or any element is not a plain object.
+ * @throws Error if any entry has no non-empty `uid`.
+ */
+export function getVariantAliases(entries: CDAEntryLike[], contentTypeUid?: string): VariantAliasesResult[];
+
+export function getVariantAliases(
+ entryOrEntries: CDAEntryLike | CDAEntryLike[],
+ contentTypeUid?: string
+): VariantAliasesResult | VariantAliasesResult[] {
+ if (Array.isArray(entryOrEntries)) {
+ return entryOrEntries.map((e) => {
+ assertPlainObject(e, 'Each entry must be a plain object with a uid.');
+ return mapEntryToResult(e, contentTypeUid);
+ });
+ }
+ assertPlainObject(entryOrEntries, 'Entry is required. Provide a CDA entry object with a uid.');
+ return mapEntryToResult(entryOrEntries, contentTypeUid);
+}
+
+/**
+ * Serialises variant alias results for use as an HTML `data-csvariants` attribute value.
+ *
+ * @param entries - CDA entries to process (same rules as {@link getVariantAliases} for each item).
+ * @param contentTypeUid - Applied when an entry lacks `_content_type_uid`.
+ * @returns `{ "data-csvariants": "" }`.
+ * @throws TypeError if `entries` is null/undefined or not an array, or any element is not a plain object.
+ * @throws Error if any entry has no non-empty `uid`.
+ */
+export function getVariantMetadataTags(
+ entries: CDAEntryLike[],
+ contentTypeUid?: string
+): { 'data-csvariants': string } {
+ if (entries === null || entries === undefined) {
+ throw new TypeError('Entries array is required. Provide an array of CDA entry objects.');
+ }
+ if (!Array.isArray(entries)) {
+ throw new TypeError('Entries must be an array of CDA entry objects.');
+ }
+ const payload = getVariantAliases(entries, contentTypeUid);
+ return { 'data-csvariants': JSON.stringify(payload) };
+}
From a36c992e56e2df1e341bb159c96453a1609e86a6 Mon Sep 17 00:00:00 2001
From: sunil-lakshman <104969541+sunil-lakshman@users.noreply.github.com>
Date: Mon, 23 Mar 2026 13:45:43 +0530
Subject: [PATCH 2/2] fix: fixed missed coverage lines in test file
---
__test__/variant-aliases.test.ts | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/__test__/variant-aliases.test.ts b/__test__/variant-aliases.test.ts
index f9814b3..fca9ee0 100644
--- a/__test__/variant-aliases.test.ts
+++ b/__test__/variant-aliases.test.ts
@@ -65,11 +65,39 @@ describe('getVariantAliases', () => {
expect(sortAliases(result.variants)).toEqual(sortAliases(['keep_me', 'also_keep']));
});
+ it('skips variant entries that are null, non-objects, or arrays', () => {
+ const variants: Record = {
+ skip_null: null,
+ skip_string: 'not-an-object',
+ skip_array: [1, 2],
+ keep: { alias: 'only_valid' },
+ };
+ const entry = {
+ uid: 'u1',
+ publish_details: {
+ variants,
+ },
+ };
+ const result = getVariantAliases(entry);
+ expect(result.variants).toEqual(['only_valid']);
+ });
+
it('throws when entry is null or undefined', () => {
expect(() => getVariantAliases(null as unknown as Record)).toThrow();
expect(() => getVariantAliases(undefined as unknown as Record)).toThrow();
});
+ it('throws TypeError when single entry is a non-object (e.g. primitive)', () => {
+ expect(() => getVariantAliases(42 as unknown as Record)).toThrow(TypeError);
+ expect(() => getVariantAliases('entry' as unknown as Record)).toThrow(TypeError);
+ });
+
+ it('throws TypeError when an array item is not a plain object', () => {
+ expect(() =>
+ getVariantAliases([variantEntrySingle, [] as unknown as Record])
+ ).toThrow(TypeError);
+ });
+
it('throws when entry uid is missing or empty', () => {
expect(() => getVariantAliases({})).toThrow(/uid/i);
expect(() => getVariantAliases({ uid: '' })).toThrow(/uid/i);