Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
28ea4b2
Merge pull request #533 from contentstack/VB-138+sync-dev
hiteshshetty-dev Jan 2, 2026
03964af
feat: implement isValidCslp function for CSLP value validation and ad…
hiteshshetty-dev Jan 13, 2026
726a417
Merge branch 'stage_v4' into develop_v4
karancs06 Jan 13, 2026
a915960
Merge remote-tracking branch 'origin/develop_v4' into VB-694+sync
hiteshshetty-dev Jan 13, 2026
ad42ec1
test: mock extractDetailsFromCslp function in useRevalidateFieldDataP…
hiteshshetty-dev Jan 13, 2026
b1db0fc
test: enhance tests by mocking isValidCslp function in variant-relate…
hiteshshetty-dev Jan 13, 2026
bbfcf29
Merge remote-tracking branch 'origin/VB-694' into VB-694+sync
hiteshshetty-dev Jan 13, 2026
9496491
Merge pull request #547 from contentstack/VB-694+sync
hiteshshetty-dev Jan 16, 2026
db0fa6a
chore: update tsup configuration to ignore legacy build & dts during …
hiteshshetty-dev Jan 16, 2026
52665c4
chore: upgrade tsup to latest version
hiteshshetty-dev Jan 16, 2026
45d85f4
chore: update doc for dev build mode
hiteshshetty-dev Jan 16, 2026
5aa5327
Merge remote-tracking branch 'origin/stage_v4' into VB-676
hiteshshetty-dev Jan 16, 2026
02410be
Merge pull request #548 from contentstack/VB-676
hiteshshetty-dev Jan 22, 2026
2103266
chore: version bump
csAyushDubey Jan 27, 2026
f96d848
chore: lodash-es version upgrade
csAyushDubey Jan 27, 2026
7e90efb
feat: vb to ve
karancs06 Feb 16, 2026
64dfdc6
Merge remote-tracking branch 'origin/main' into develop_v4
hiteshshetty-dev Feb 16, 2026
d05112c
Merge pull request #554 from contentstack/vb-to-ve-support
karancs06 Feb 16, 2026
d2eadb9
Merge remote-tracking branch 'origin/develop_v4' into develop_v4
hiteshshetty-dev Feb 16, 2026
f4e723e
Vp 444 stag sync 2 (#562)
csAdityaPachauri Mar 13, 2026
48e0c35
chore: audit fix
karancs06 Mar 18, 2026
c8fc00d
Merge pull request #565 from contentstack/develop_v4
karancs06 Mar 18, 2026
a53ba7c
Merge branch 'main' into stage_v4
csAdityaPachauri Mar 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Alternatively, if you want to include the package directly in your website HTML

```html
<script type='module' crossorigin="anonymous">
import ContentstackLivePreview from 'https://esm.sh/@contentstack/live-preview-utils@4.2.1';
import ContentstackLivePreview from 'https://esm.sh/@contentstack/live-preview-utils@4.3.0';

ContentstackLivePreview.init({
stackDetails: {
Expand Down
336 changes: 196 additions & 140 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@contentstack/live-preview-utils",
"version": "4.2.1",
"version": "4.3.0",
"description": "Contentstack provides the Live Preview SDK to establish a communication channel between the various Contentstack SDKs and your website, transmitting live changes to the preview pane.",
"type": "module",
"types": "dist/legacy/index.d.ts",
Expand Down Expand Up @@ -29,7 +29,7 @@
"test:watch": "vitest",
"test:once": "vitest run",
"test:coverage": "vitest --coverage",
"dev": "NODE_OPTIONS='--max-old-space-size=16384' tsup --watch",
"dev": "NODE_OPTIONS='--max-old-space-size=16384' tsup --watch --config tsup.config.dev.js",
"prepare": "husky",
"lint": "eslint src",
"lint:fix": "eslint --fix",
Expand Down Expand Up @@ -73,7 +73,7 @@
"prettier-eslint": "^15.0.1",
"ts-node": "^10.9.2",
"tsc": "^2.0.4",
"tsup": "^8.0.1",
"tsup": "^8.5.1",
"tsx": "^4.19.1",
"typedoc": "^0.25.13",
"typescript": "^5.4.5",
Expand Down
14 changes: 13 additions & 1 deletion src/configManager/__test__/configManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Config, { updateConfigFromUrl } from "../configManager";
import { getDefaultConfig } from "../config.default";
import { getDefaultConfig, getUserInitData } from "../config.default";
import { DeepSignal } from "deepsignal";
import { IConfig } from "../../types/types";

Expand Down Expand Up @@ -90,6 +90,18 @@ describe("Config", () => {
});
});

describe("config default flags", () => {
test("enableLivePreviewOutsideIframe defaults to undefined in getDefaultConfig", () => {
const defaultConfig = getDefaultConfig();
expect(defaultConfig.enableLivePreviewOutsideIframe).toBeUndefined();
});

test("enableLivePreviewOutsideIframe defaults to undefined in getUserInitData", () => {
const initData = getUserInitData();
expect(initData.enableLivePreviewOutsideIframe).toBeUndefined();
});
});

describe("update config from url", () => {
let config: DeepSignal<IConfig>;

Expand Down
35 changes: 35 additions & 0 deletions src/configManager/__test__/handleUserConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,41 @@ describe("handleInitData()", () => {
});
});

describe("handleInitData() - enableLivePreviewOutsideIframe", () => {
let config: DeepSignal<IConfig>;

beforeEach(() => {
Config.reset();
config = Config.get();
});

afterAll(() => {
Config.reset();
});

test("should default to undefined when not provided", () => {
const initData: Partial<IInitData> = {};
handleInitData(initData);
expect(config.enableLivePreviewOutsideIframe).toBeUndefined();
});

test("should set to true when provided as true", () => {
const initData: Partial<IInitData> = {
enableLivePreviewOutsideIframe: true,
};
handleInitData(initData);
expect(config.enableLivePreviewOutsideIframe).toBe(true);
});

test("should remain false when provided as false", () => {
const initData: Partial<IInitData> = {
enableLivePreviewOutsideIframe: false,
};
handleInitData(initData);
expect(config.enableLivePreviewOutsideIframe).toBe(false);
});
});

describe("handleClientUrlParams()", () => {
let config: DeepSignal<IConfig>;

Expand Down
2 changes: 2 additions & 0 deletions src/configManager/config.default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function getUserInitData(): IInitData {
environment: "",
},
runScriptsOnUpdate: false,
enableLivePreviewOutsideIframe: undefined,
};
}

Expand Down Expand Up @@ -110,5 +111,6 @@ export function getDefaultConfig(): IConfig {
},
payload: [],
},
enableLivePreviewOutsideIframe: undefined,
};
}
6 changes: 6 additions & 0 deletions src/configManager/handleUserConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ export const handleInitData = (initData: Partial<IInitData>): void => {
"bottom-right",
})

Config.set(
"enableLivePreviewOutsideIframe",
initData.enableLivePreviewOutsideIframe ??
config.enableLivePreviewOutsideIframe
);

// client URL params
handleClientUrlParams(
initData.clientUrlParams ??
Expand Down
122 changes: 121 additions & 1 deletion src/cslp/__test__/cslpdata.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,124 @@
import { extractDetailsFromCslp } from "../cslpdata";
import { extractDetailsFromCslp, isValidCslp } from "../cslpdata";

describe("isValidCslp", () => {
describe("valid cases", () => {
test("should return true for valid v1 format with 3 parts", () => {
expect(
isValidCslp("content_type_uid.entry_uid.locale")
).toBeTruthy();
});

test("should return true for valid v1 format with field path", () => {
expect(
isValidCslp("content_type_uid.entry_uid.locale.field_path")
).toBeTruthy();
});

test("should return true for valid v2 format with 3 parts", () => {
expect(
isValidCslp("v2:content_type_uid.entry_uid_variant_uid.locale")
).toBeTruthy();
});

test("should return true for valid v2 format with field path", () => {
expect(
isValidCslp(
"v2:content_type_uid.entry_uid_variant_uid.locale.field_path"
)
).toBeTruthy();
});
});

describe("invalid cases", () => {
test("should return false for null", () => {
expect(isValidCslp(null)).toBeFalsy();
});

test("should return false for undefined", () => {
expect(isValidCslp(undefined)).toBeFalsy();
});

test("should return false for empty string", () => {
expect(isValidCslp("")).toBeFalsy();
});

test("should return false for string with less than 3 parts", () => {
expect(isValidCslp("invalid")).toBeFalsy();
});

test("should return false for string with only 2 parts", () => {
expect(isValidCslp("a.b")).toBeFalsy();
});

test("should return false for v2 prefix with no data", () => {
expect(isValidCslp("v2:")).toBeFalsy();
});

test("should return false for v2 prefix with only 2 parts", () => {
expect(isValidCslp("v2:a.b")).toBeFalsy();
});

test("should return false for v2 format where entry_uid_variant_uid has no underscore", () => {
expect(
isValidCslp("v2:content_type_uid.entry.locale")
).toBeFalsy();
});

test("should return false for v2 format where entry_uid_variant_uid is missing variant_uid", () => {
expect(
isValidCslp("v2:content_type_uid.entry_.locale")
).toBeFalsy();
});

test("should return false for v2 format where entry_uid_variant_uid is missing entry_uid", () => {
expect(
isValidCslp("v2:content_type_uid._variant_uid.locale")
).toBeFalsy();
});

test("should return false for v2 format with empty parts", () => {
expect(isValidCslp("v2:..locale")).toBeFalsy();
});

test("should return false for v2 format with empty content_type_uid", () => {
expect(isValidCslp("v2:.entry_uid_variant_uid.locale")).toBeFalsy();
});

test("should return false for v2 format with empty locale", () => {
expect(
isValidCslp("v2:content_type_uid.entry_uid_variant_uid.")
).toBeFalsy();
});

test("should return false for v1 format with empty parts", () => {
expect(isValidCslp("..locale")).toBeFalsy();
});

test("should return false for v1 format with empty content_type_uid", () => {
expect(isValidCslp(".entry_uid.locale")).toBeFalsy();
});

test("should return false for v1 format with empty entry_uid", () => {
expect(isValidCslp("content_type_uid..locale")).toBeFalsy();
});

test("should return false for whitespace-only string", () => {
expect(isValidCslp(" ")).toBeFalsy();
});

test("should return false for tab and newline whitespace", () => {
expect(isValidCslp("\t\n")).toBeFalsy();
});

test("should return false for string with only dots", () => {
expect(isValidCslp("...")).toBeFalsy();
});

test("should return false for string with only two dots", () => {
expect(isValidCslp("..")).toBeFalsy();
});
});
});

describe("extractDetailsFromCslp", () => {
test("should extract details from a CSLP value string with nested multiple field", () => {
Expand Down
72 changes: 71 additions & 1 deletion src/cslp/cslpdata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,76 @@ import Config from "../configManager/configManager";
import { DeepSignal } from "deepsignal";
import { cslpTagStyles } from "../livePreview/editButton/editButton.style";

/**
* Validates that the required CSLP parts (content_type_uid, entry_uid/entry_uid_variant_uid, locale) are non-empty.
* @param parts The array of parts from splitting the CSLP string by "."
* @returns `true` if all required parts (first 3) are non-empty, `false` otherwise.
*/
function areRequiredPartsNonEmpty(parts: string[]): boolean {
// Check that we have at least 3 parts
if (parts.length < 3) {
return false;
}
// Verify that content_type_uid (parts[0]), entry_uid/entry_uid_variant_uid (parts[1]), and locale (parts[2]) are all non-empty
return parts[0].length > 0 && parts[1].length > 0 && parts[2].length > 0;
}

/**
* Validates if a CSLP value string is valid.
*
* Supports two formats:
* - **v1 format**: `content_type_uid.entry_uid.locale[.field_path]` (requires at least 3 parts)
* - **v2 format**: `v2:content_type_uid.entry_uid_variant_uid.locale[.field_path]`
* (requires at least 3 parts, entry_uid_variant_uid must contain underscore separating entry_uid and variant_uid)
*
* @param cslpValue The CSLP value string to validate (can be null or undefined).
* @returns Type predicate: `true` if the CSLP value is valid (narrows type to `string`), `false` otherwise.
*
* @example
* Valid v1 format
* isValidCslp("page.entry123.en-us") -> true
* isValidCslp("page.entry123.en-us.title") -> true
*
* Valid v2 format
* isValidCslp("v2:page.entry123_variant456.en-us") -> true
* isValidCslp("v2:page.entry123_variant456.en-us.title") -> true
*
* Invalid cases
* isValidCslp(null) -> false
* isValidCslp("invalid") -> false (less than 3 parts)
* isValidCslp("v2:page.entry123.en-us") -> false (missing underscore in entry_uid_variant_uid)
*/
export function isValidCslp(
cslpValue: string | null | undefined
): cslpValue is string {
// Return false for null, undefined, or empty string
if (!cslpValue) {
return false;
}

// Check for v2 format (starts with "v2:")
if (cslpValue.startsWith("v2:")) {
const dataAfterPrefix = cslpValue.substring(3); // Remove "v2:" prefix
const parts = dataAfterPrefix.split(".");
// v2 format requires at least 3 parts: content_type_uid.entry_uid_variant_uid.locale
// Verify that content_type_uid, entry_uid_variant_uid, and locale are all non-empty
if (!areRequiredPartsNonEmpty(parts)) {
return false;
}
// Verify that entry_uid_variant_uid (second part) contains both entry_uid and variant_uid separated by at least one underscore
const entryUidVariantUid = parts[1];
const entryVariantParts = entryUidVariantUid.split("_");
// Check that we have at least 2 parts (entry_uid and variant_uid) and all parts are non-empty
return entryVariantParts.length >= 2 && entryVariantParts.every((part) => part.length > 0);
}

// v1 format (default, no prefix)
const parts = cslpValue.split(".");
// v1 format requires at least 3 parts: content_type_uid.entry_uid.locale
// Verify that content_type_uid, entry_uid, and locale are all non-empty
return areRequiredPartsNonEmpty(parts);
}

/**
* Extracts details from a CSLP value string.
* @param cslpValue The CSLP value string to extract details from.
Expand Down Expand Up @@ -163,7 +233,7 @@ export function addCslpOutline(

const cslpTag = element.getAttribute("data-cslp");

if (trigger && cslpTag) {
if (trigger && isValidCslp(cslpTag)) {
if (elements.highlightedElement)
elements.highlightedElement.classList.remove(
cslpTagStyles()["cslp-edit-mode"]
Expand Down
4 changes: 2 additions & 2 deletions src/livePreview/editButton/editButton.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { effect } from "@preact/signals";
import { inIframe, isOpeningInNewTab } from "../../common/inIframe";
import Config from "../../configManager/configManager";
import { addCslpOutline, extractDetailsFromCslp } from "../../cslp";
import { addCslpOutline, extractDetailsFromCslp, isValidCslp } from "../../cslp";
import { cslpTagStyles } from "./editButton.style";
import { PublicLogger } from "../../logger/logger";
import {
Expand Down Expand Up @@ -439,7 +439,7 @@ export class LivePreviewEditButton {

const cslpTag = this.tooltip.getAttribute("current-data-cslp");

if (cslpTag) {
if (isValidCslp(cslpTag)) {
const {
content_type_uid,
entry_uid,
Expand Down
Loading
Loading