diff --git a/.cursor/rules/README.md b/.cursor/rules/README.md
new file mode 100644
index 0000000..b4b2107
--- /dev/null
+++ b/.cursor/rules/README.md
@@ -0,0 +1,26 @@
+# Cursor rules — `@contentstack/utils`
+
+This folder holds project-specific rules for AI assistants working in **contentstack-utils-javascript**. For the full project overview, see **[`AGENTS.md`](../../AGENTS.md)** at the repository root.
+
+## How rules are picked up
+
+Each rule is a `.md`/`.mdc` file. Files with YAML frontmatter can set `description`, `globs`, and `alwaysApply`. Cursor uses these to decide when a rule is included in context.
+
+### Referencing rules in chat
+
+In Cursor, you can **`@`-mention** a rule file (e.g. type `@` and choose the rule from the list) to force its guidance into the conversation. The file name (without extension) is the usual handle, e.g. **`typescript`**, **`testing`**, **`code-review`**.
+
+## Rule index
+
+| File | `alwaysApply` | Globs | When it applies |
+|------|----------------|-------|------------------|
+| [`dev-workflow.md`](dev-workflow.md) | no | *(none)* | Branching, CI alignment, commits, releases, and day-to-day commands for this repo. |
+| [`typescript.mdc`](typescript.mdc) | no | `src/**/*.ts`, `__test__/**/*.ts` | TypeScript version, layout, ESLint/Prettier, strictness, imports. |
+| [`typescript-contentstack-utils.mdc`](typescript-contentstack-utils.mdc) | no | `src/**/*.ts` | Delivery-oriented utils only: RTE rendering, GQL helpers, endpoints JSON, Live Preview tags—**not** full CDA/CMA SDK surface. |
+| [`testing.mdc`](testing.mdc) | no | `__test__/**/*.ts` | Jest, jsdom, mocks, coverage output paths, no live-test env. |
+| [`code-review.mdc`](code-review.mdc) | **yes** | *(global)* | PR checklist: public API docs, compatibility, errors, dependencies, terminology (utils + delivery context). |
+
+## Related
+
+- **[`AGENTS.md`](../../AGENTS.md)** — Single entry point (package purpose, stack, commands).
+- **[`skills/README.md`](../../skills/README.md)** — Longer-form skill docs for the same themes.
diff --git a/.cursor/rules/code-review.mdc b/.cursor/rules/code-review.mdc
new file mode 100644
index 0000000..0e5fc27
--- /dev/null
+++ b/.cursor/rules/code-review.mdc
@@ -0,0 +1,41 @@
+---
+description: PR review checklist for @contentstack/utils — API docs, compatibility, security, testing
+alwaysApply: true
+---
+
+# Code review checklist — `@contentstack/utils`
+
+Use severity labels (**Blocker / Major / Minor**) when triaging findings.
+
+## Public API and documentation
+
+- **Blocker/Major:** New or changed **exports** from `src/index.ts` need accurate **JSDoc** (or clear type names) matching runtime behavior.
+- **Major:** README / CHANGELOG updates when behavior is user-visible or migration is needed.
+
+## Backward compatibility
+
+- **Blocker:** Unplanned breaking changes to **function signatures** or **default behavior** consumed by Delivery SDK integrations or documented `renderOption` contracts.
+- **Major:** Stricter throwing on inputs that previously passed (especially `getContentstackEndpoint`, RTE traversals).
+
+## Errors
+
+- This package uses **plain `Error`** (e.g. `endpoints.ts`); new code should keep messages actionable. **Major:** Silent failures where callers need to detect bad input.
+
+## Null safety and RTE edge cases
+
+- **Major:** Missing guards on **null/undefined** node or entry fragments (historically sensitive in `entry-editable` / RTE paths).
+- **Minor:** Align with **`strictNullChecks: false`** legacy but avoid widening undefined leaks into public types.
+
+## Dependencies and SCA
+
+- **Major:** New runtime deps are rare—justify any addition; **`prepublishOnly`** and hooks assume **`npm test`** and Snyk-friendly trees.
+- Use **`npm audit` / Snyk** expectations per org policy.
+
+## Tests
+
+- **Blocker:** Behavioral fixes or new branches without **`__test__`** coverage when risk is high (RTE nesting, GQL URL rewriting, endpoint resolution).
+- **Minor:** Snapshot-only tests where a small assertion would be clearer.
+
+## Terminology
+
+- **Major:** Docs/comments must describe this as **utils** alongside **CDA / Delivery / JSON RTE / GraphQL**, not as **CMA** unless the change is explicitly management-related.
diff --git a/.cursor/rules/dev-workflow.md b/.cursor/rules/dev-workflow.md
new file mode 100644
index 0000000..0453f3c
--- /dev/null
+++ b/.cursor/rules/dev-workflow.md
@@ -0,0 +1,38 @@
+---
+description: Branching, CI, local commands, commit hooks, and release flow for contentstack-utils-javascript
+alwaysApply: false
+---
+
+# Dev workflow — `@contentstack/utils`
+
+## Branches and PRs
+
+- **CI** (`Unit-Test-CI`) runs on **push/PR** to `development`, `staging`, and `master` (see `.github/workflows/ci.yml`).
+- **Branch protection:** A workflow fails PRs that target **`staging`** when the head branch is **not** `development` (see `.github/workflows/check-branch.yml`). Prefer **`development`** as the integration branch when contributing upstream.
+- Target the branch your team uses for integration; align with maintainers if unsure.
+
+## Local development
+
+| Task | Command / note |
+|------|----------------|
+| Build | `npm run build` (runs `prebuild`: clean `dist`, `download-regions`, then `tsc` + Rollup) |
+| Test | `npm test` (includes `pretest` → build; Jest + coverage; `reports/`) |
+| Format | `npm run format` (Prettier: `src/**/*.ts`) |
+| ESLint | No `lint` script in `package.json`; run e.g. `npx eslint src __test__` using `eslint.config.js` |
+
+## Git hooks (Husky)
+
+- **`commit-msg`:** [Conventional Commits](https://www.conventionalcommits.org/) via **`commitlint`** (`.commitlintrc.json`).
+- **`pre-commit`:** Requires **Snyk** (`snyk test --all-projects`) and **Talisman** installed locally. Set `SKIP_HOOK=1` to bypass (documented in the hook).
+
+## PR expectations
+
+- **`npm test`** passes (build + unit tests).
+- **CHANGELOG.md** updated for user-visible changes when maintainers expect it.
+- **Dependencies:** Be mindful of Snyk/SCA workflows; security-relevant bumps should be justified.
+
+## Releases and versioning
+
+- **Package version** lives in **`package.json`** (also reflected in **`CHANGELOG.md`**).
+- **`prepublishOnly`** runs **`npm test`** before publish.
+- **NPM / GitHub Packages:** Publishing is driven by **GitHub release `created`** (`.github/workflows/npm-publish.yml`); requires maintainer secrets (`NPM_TOKEN`, `GIT_TOKEN`). Bump version and changelog in the same change set as the release your team uses.
diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc
new file mode 100644
index 0000000..1b7f67e
--- /dev/null
+++ b/.cursor/rules/testing.mdc
@@ -0,0 +1,38 @@
+---
+description: Jest tests, mocks, reports, and environments for @contentstack/utils
+globs:
+ - __test__/**/*.ts
+alwaysApply: false
+---
+
+# Testing — `@contentstack/utils`
+
+## Framework
+
+- **Jest 29** with **`ts-jest`** preset (`jest.config.ts`).
+- **Environment:** **`jsdom`** (suitable for HTML string assertions and DOM-related behavior).
+
+## Discovery and naming
+
+- **Pattern:** `**/__test__/**/?(*.)+(spec|test).[jt]s?(x)` — this repo uses `*.test.ts` under **`__test__/`**.
+- **Mocks / fixtures:** `__test__/mock/*.ts` — follow existing mock naming (`*-mock.ts`).
+
+## Coverage and reports
+
+- **Coverage** is collected from `src/**` excluding `src/index.ts` (`collectCoverageFrom`).
+- Outputs: **`reports/coverage/`**, **`reports/html/`** (jest-html-reporters), **`reports/junit/`** (jest-junit), **`reports/report.json`** (from `npm test` script).
+
+## Running tests
+
+| Command | Behavior |
+|---------|----------|
+| `npm test` | Runs **`pretest` → build** then Jest (CI-aligned). |
+| `npm run test:debug` | Watch mode, `--runInBand`. |
+
+## Integration / live tests
+
+- **None** in this repo. No `.env` or stack credentials required for **`npm test`**.
+
+## Build dependency
+
+- **`npm test` always builds first** (`pretest`). Ensure **`src/assets/regions.json`** exists or can be downloaded so `endpoints` tests pass.
diff --git a/.cursor/rules/typescript-contentstack-utils.mdc b/.cursor/rules/typescript-contentstack-utils.mdc
new file mode 100644
index 0000000..70f23c6
--- /dev/null
+++ b/.cursor/rules/typescript-contentstack-utils.mdc
@@ -0,0 +1,34 @@
+---
+description: Contentstack utils package patterns — RTE, embedded items, GQL, endpoints (delivery-oriented companion, not CDA/CMA SDK)
+globs:
+ - src/**/*.ts
+alwaysApply: false
+---
+
+# `@contentstack/utils` — domain patterns
+
+This repository implements **companion utilities** for Contentstack **content consumption** scenarios (typically with the **Delivery SDK** and **CDA / GraphQL** responses). It does **not** implement stack configuration, delivery tokens, or HTTP clients—callers fetch entry JSON elsewhere and pass it into these helpers.
+
+## Mental model
+
+| Area | Source anchors | Notes |
+|------|----------------|-------|
+| Render options / callbacks | `src/options/`, `src/render-embedded-objects.ts` | `RenderOption`-style maps: node types, marks, `block` / `inline` embeds, `reference`, `display`, `default`. |
+| JSON RTE → HTML | `src/json-to-html.ts`, `src/nodes/*`, `src/helper/*` | Supercharged RTE path uses `paths` arrays for nested fields (see `README.md`). |
+| Embedded object types | `src/Models/embedded-object.ts`, `src/Models/json-rte-model.ts` | Align typings with shapes coming from **delivery** entry JSON, not management APIs. |
+| GraphQL | `src/gql.ts`, `src/updateAssetURLForGQL.ts` | Docs/comments describe use **after** GraphQL fetches; asset URL rewriting is response-shape specific. |
+| Live Preview / CSLP tags | `src/entry-editable.ts` | `addTags` / exported `addEditableTags` — preserve existing attribute and locale behavior when changing tag generation. |
+| Region endpoints | `src/endpoints.ts` | **`getContentstackEndpoint(region, service?, omitHttps?)`** reads **`src/assets/regions.json`**; throws plain `Error` with human-readable messages on bad region/service. |
+
+## Terminology
+
+- Prefer **CDA / Delivery / JSON RTE / GraphQL** vocabulary when describing behavior—**not** CMA unless you are explicitly discussing something management-only (rare here).
+- **“SDK”** in docs often means **this utils package** or the **Delivery SDK** together—disambiguate in user-facing text.
+
+## Concurrency / IO
+
+- Pure synchronous transforms dominate; no retry or rate-limit layer. Do not add HTTP clients without an explicit product requirement.
+
+## Official docs alignment
+
+- Behavior should stay consistent with current **Content Delivery** / **Live Preview** / **JSON RTE** documentation. When in doubt, cite the relevant Contentstack doc set in PR descriptions rather than guessing CMS semantics.
diff --git a/.cursor/rules/typescript.mdc b/.cursor/rules/typescript.mdc
new file mode 100644
index 0000000..ad2d6fb
--- /dev/null
+++ b/.cursor/rules/typescript.mdc
@@ -0,0 +1,34 @@
+---
+description: TypeScript conventions for @contentstack/utils (src and tests)
+globs:
+ - src/**/*.ts
+ - __test__/**/*.ts
+alwaysApply: false
+---
+
+# TypeScript — `@contentstack/utils`
+
+## Tooling
+
+- **TypeScript ~4.9** (`package.json`, `tsconfig.json`).
+- **Bundler output:** `tsc` emits to `dist/lib` with declarations in `dist/types`; **Rollup** produces `dist/index.es.js` (`rollup.config.js`).
+- **Strict mode** is on with **`strictNullChecks: false`** — new code should still minimize unnecessary null/undefined gaps when touching APIs.
+
+## Layout
+
+- **Library code:** `src/**/*.ts` only (`tsconfig.json` `include`); **tests** are under `__test__/` (excluded from `tsc` program but type-checked via Jest/tsconfig paths).
+- **Public exports:** Maintain **`src/index.ts`** as the single export surface unless intentionally adding secondary entry points.
+
+## Style and lint
+
+- Match existing naming (PascalCase types, camelCase functions, existing file layout under `Models/`, `nodes/`, `helper/`, `options/`).
+- **ESLint** flat config: `eslint.config.js` — `@typescript-eslint` recommended for `__test__`; source files use `js.configs.recommended` baseline.
+- **Prettier:** `npm run format` scopes `src/**/*.ts`; keep formatting consistent when editing tests too.
+
+## JSON / assets
+
+- **`src/assets/regions.json`:** Consumed by `src/endpoints.ts`; populated by **`npm run download-regions`** during `prebuild`. Do not assume the file is committed (see root `.gitignore`).
+
+## Logging
+
+- No shared logger dependency; avoid introducing `console` noise in library paths unless consistent with nearby code.
diff --git a/.talismanrc b/.talismanrc
index dbc2d7e..78e5520 100644
--- a/.talismanrc
+++ b/.talismanrc
@@ -26,3 +26,9 @@ fileignoreconfig:
checksum: a843710fc9f54bf4c7996f39561dc66491d62a9d9eeca50fa2c7c37bd6141f53
- filename: src/render-embedded-objects.ts
checksum: 35d56d4f7b625611fef18414fccdbff014c1d90d02e17eb0efa4d6166b73e23b
+- filename: .cursor/rules/code-review.mdc
+ checksum: 43d4fddb706e5ad6c92fe0af30bd5f44eec9b83c31dc93d3d4cc5e6208d351cc
+- filename: .cursor/rules/dev-workflow.md
+ checksum: b9a4d68439ce9564730506ab6c4712f5034a645858bdb78ac57e43ba183cb981
+- filename: skills/code-review/SKILL.md
+ checksum: 4ec638a87a795e5cc0b0e66b569e04749cb27f9d3be29d4ea69f56b48225c943
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..0798e0e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,52 @@
+# Agent guidance — `@contentstack/utils`
+
+## What this package is
+
+**[@contentstack/utils](https://www.npmjs.com/package/@contentstack/utils)** (`contentstack-utils-javascript`) is a **JavaScript/TypeScript utilities library** for Contentstack. It is **not** the Content Delivery API (CDA) SDK or the Content Management API (CMA) SDK. It focuses on **JSON RTE / Supercharged RTE** rendering (`jsonToHTML`), **embedded entry and asset** rendering (`render`, `renderContent`), **GraphQL-oriented helpers** (`GQL`, `updateAssetURLForGQL`), **Live Preview–style editable tags** (`addEditableTags` / `addTags`), and **region endpoint lookup** (`getContentstackEndpoint`). Typical usage is **alongside** the Delivery SDK (see repository `README.md` examples with `@contentstack/delivery-sdk`).
+
+- **Repository:** [github.com/contentstack/contentstack-utils-javascript](https://github.com/contentstack/contentstack-utils-javascript)
+
+## Tech stack
+
+| Area | Details |
+|------|---------|
+| Language | TypeScript **4.9** (`tsconfig.json`, `strict: true`, `strictNullChecks: false`) |
+| Build | **TypeScript** (`tsc`) → `dist/lib`; **Rollup** (`rollup -c`) → `dist/index.es.js`; types in `dist/types/` |
+| Test | **Jest 29** + **ts-jest**, **jsdom** environment (`jest.config.ts`) |
+| Lint / format | **ESLint 9** flat config (`eslint.config.js`); **Prettier 3** (`npm run format`) — there is **no** `lint` npm script; use `npx eslint` as needed |
+| Runtime HTTP / JSON for API calls | **None** in library code; `regions.json` is a **build-time** asset (see below) |
+
+## Source layout and public API
+
+| Role | Path |
+|------|------|
+| Public entry (sources) | `src/index.ts` |
+| Options / render types | `src/options/` |
+| RTE / node model | `src/Models/`, `src/nodes/` |
+| Helpers | `src/helper/` |
+| GQL + asset URL rewrite | `src/gql.ts`, `src/updateAssetURLForGQL.ts` |
+| Endpoints helper | `src/endpoints.ts` + `src/assets/regions.json` (generated; see build) |
+| Published bundle | `dist/` (per `package.json` `main` / `types`) |
+
+## Common commands
+
+| Command | Purpose |
+|---------|---------|
+| `npm run build` | Cleans `dist`, ensures `src/assets/regions.json` (download or warning), runs `tsc` + Rollup |
+| `npm test` | Runs `pretest` → **build**, then Jest with coverage; outputs under `reports/` |
+| `npm run test:debug` | Jest watch, in-band |
+| `npm run format` | Prettier on `src/**/*.ts` |
+| `npm run download-regions` | Fetches `regions.json` only (used by `prebuild`) |
+
+**Tests:** Unit tests only, under `__test__/**/*.test.ts`, with mocks in `__test__/mock/`. There are **no** live/integration tests requiring stack credentials in this repository.
+
+## Credentials / environment
+
+- **Unit tests:** No API keys or `.env` required.
+- **Build:** `download-regions` calls a public URL (`artifacts.contentstack.com`); offline builds may warn and rely on an existing `src/assets/regions.json`. Note `regions.json` is listed in `.gitignore`; clones may need a successful `npm run build` (or manual file) before tests pass.
+- **Publish:** GitHub release workflow uses `NPM_TOKEN` / `GIT_TOKEN` secrets (maintainers only).
+
+## More detail for AI / IDE rules
+
+- [`.cursor/rules/README.md`](.cursor/rules/README.md) — Cursor rules index (`alwaysApply`, globs, when to use).
+- [`skills/README.md`](skills/README.md) — Topic skills (testing, code review, package mental model).
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..fca9ee0
--- /dev/null
+++ b/__test__/variant-aliases.test.ts
@@ -0,0 +1,136 @@
+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('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);
+ });
+
+ 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/skills/README.md b/skills/README.md
new file mode 100644
index 0000000..72354af
--- /dev/null
+++ b/skills/README.md
@@ -0,0 +1,11 @@
+# Skills — `@contentstack/utils`
+
+Short-form guides for humans and agents. Root context: **[`AGENTS.md`](../AGENTS.md)**. Cursor rule index: **[`.cursor/rules/README.md`](../.cursor/rules/README.md)**.
+
+| Skill | When to use |
+|-------|--------------|
+| [`code-review/`](code-review/SKILL.md) | Before opening or approving a PR; expanded checklist (API docs, compatibility, errors, tests, terminology). |
+| [`testing/`](testing/SKILL.md) | Running Jest, understanding `pretest`/build, mocks, reports, and why no live API tests exist here. |
+| [`typescript-contentstack-utils/`](typescript-contentstack-utils/SKILL.md) | Mental map of RTE/embed/GQL/endpoints/Live Preview code paths and where to change behavior safely. |
+
+There is **no** separate `framework` skill: this repo has **no** shared HTTP client or retry layer.
diff --git a/skills/code-review/SKILL.md b/skills/code-review/SKILL.md
new file mode 100644
index 0000000..066047c
--- /dev/null
+++ b/skills/code-review/SKILL.md
@@ -0,0 +1,44 @@
+# Skill: Code review — `@contentstack/utils`
+
+## When to use
+
+Final pass before merge, or when authoring a PR to self-check against team expectations.
+
+## Checklist (expanded)
+
+### Public API and docs
+
+- Exported symbols from **`src/index.ts`** stay coherent: names match behavior, types match real JSON RTE / entry shapes from **delivery** content.
+- **JSDoc** on public functions (`getContentstackEndpoint`, `jsonToHTML`, `render`, GQL helpers, etc.) matches parameters, throws, and return shapes.
+- **README.md** examples remain valid for **`@contentstack/delivery-sdk`** + **`Contentstack.Utils`** style usage when touching those flows.
+
+### Backward compatibility
+
+- Avoid breaking **`RenderOption`** callback contracts or default HTML output without a major version plan and **CHANGELOG** entry.
+- Changes to **`regions.json`** / endpoint keys must stay aligned with upstream region definitions.
+
+### Errors
+
+- Prefer clear **`Error`** messages for invalid regions, missing services, or malformed internal state—callers often log these verbatim.
+
+### Security and dependencies
+
+- New **npm dependencies** need justification (`package.json` is dev-heavy already).
+- No secrets or tokens in code; **Talisman** pre-commit enforces secret scanning locally for contributors with hooks installed.
+
+### Tests
+
+- **`npm test`** must pass (includes full **build**).
+- Add or update **`__test__`** cases for RTE edge cases, GQL URL rewriting, and endpoint error paths when logic changes.
+
+### Severity rubric (optional)
+
+| Level | Examples |
+|-------|-----------|
+| **Blocker** | Breaks published API, break consumers without semver major, removes tests on critical path |
+| **Major** | Missing docs for new export, behavior change without tests, confusing error surfaces |
+| **Minor** | Naming nits, internal refactors with equivalent coverage |
+
+### Terminology
+
+- Use **delivery / CDA / JSON RTE / GraphQL / Live Preview** context—not **CMA**—unless the work truly relates to management APIs.
diff --git a/skills/testing/SKILL.md b/skills/testing/SKILL.md
new file mode 100644
index 0000000..0f8b834
--- /dev/null
+++ b/skills/testing/SKILL.md
@@ -0,0 +1,31 @@
+# Skill: Testing — `@contentstack/utils`
+
+## When to use
+
+Setting up locally, debugging failures, or adding tests.
+
+## Commands
+
+- **`npm test`** — Runs **`pretest`** (→ **`npm run build`**) then **Jest** with coverage. Builds **`dist/`** and ensures `regions.json` flow runs.
+- **`npm run test:debug`** — Jest **`--watchAll`** in **`--runInBand`** mode.
+
+## Environment
+
+- **No API keys or `.env`** for tests—all **unit** tests with fixtures under **`__test__/mock/`**.
+- First-time or clean machines: if **`src/assets/regions.json`** is missing, **`prebuild`** attempts **`download-regions`**; failures print a warning—see **`AGENTS.md`** / **package.json** `download-regions`.
+
+## Naming and layout
+
+- Tests live in **`__test__/**/*.test.ts`** (see **`jest.config.ts` `testMatch`**).
+- Reuse **`__test__/mock/`** patterns (`entry-mock.ts`, `json-element-mock.ts`, etc.).
+
+## Reports
+
+- **Coverage:** `reports/coverage/`
+- **HTML summary:** `reports/html/`
+- **JUnit:** `reports/junit/`
+- **CI** consumes junit / coverage patterns in `.github/workflows/ci.yml` and `code.cov.yml`.
+
+## Mocks
+
+- Prefer explicit mock objects over live fetches—this package does not ship an HTTP client; do not introduce network calls in tests unless the project later adds gated integration tests.
diff --git a/skills/typescript-contentstack-utils/SKILL.md b/skills/typescript-contentstack-utils/SKILL.md
new file mode 100644
index 0000000..41115cc
--- /dev/null
+++ b/skills/typescript-contentstack-utils/SKILL.md
@@ -0,0 +1,45 @@
+# Skill: TypeScript — `@contentstack/utils` mental model
+
+## When to use
+
+Onboarding, feature design, or refactors touching RTE rendering, embeddings, GQL, or endpoints.
+
+## What this package is
+
+**`@contentstack/utils`** is a **TypeScript utility library**, not the **CDA** or **CMA** SDK. It transforms **entry JSON** already fetched via **Content Delivery** (REST or **GraphQL**) into HTML or enriched structures.
+
+## Where to change things
+
+| Goal | Start here |
+|------|------------|
+| Supercharged RTE / JSON → HTML | `src/json-to-html.ts`, `src/nodes/`, `src/helper/` |
+| Custom element / embed rendering | `src/options/`, `src/render-embedded-objects.ts` |
+| GraphQL-specific RTE or assets | `src/gql.ts`, `src/updateAssetURLForGQL.ts` |
+| Live Preview / `data-cslp`-style tags | `src/entry-editable.ts` |
+| Region → base URL lookup | `src/endpoints.ts`, `src/assets/regions.json` (build) |
+| Public surface | `src/index.ts` only |
+
+## Data flow (conceptual)
+
+```mermaid
+flowchart LR
+ subgraph external [Caller]
+ Delivery[Delivery SDK or GraphQL client]
+ end
+ subgraph utils [@contentstack/utils]
+ JSON[RTE JSON / entry object]
+ HTML[HTML string or tagged entry]
+ end
+ Delivery --> JSON
+ JSON --> HTML
+```
+
+## Conventions
+
+- Match existing **options** patterns (`RenderOption`, path arrays for nested fields).
+- Preserve **pure** transform style—no hidden network I/O.
+- Terminology in comments should say **delivery / JSON RTE / GraphQL**, not **CMA**, unless you explicitly touch management concerns (unlikely in this repo).
+
+## Docs
+
+- **Contentstack** product docs for **Delivery**, **Live Preview**, and **Rich Text / JSON RTE** are the authority for expected JSON shapes.
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) };
+}