diff --git a/.changeset/non-string-styles-params.md b/.changeset/non-string-styles-params.md new file mode 100644 index 0000000000..6493101519 --- /dev/null +++ b/.changeset/non-string-styles-params.md @@ -0,0 +1,5 @@ +--- +'@sitecore-content-sdk/content': patch +--- + +[content] Fix build crash and normalize DetailedRenderingParams object/JSON values for `Styles`, `CSSStyles`, `LibraryId`, and `GridParameters` rendering params diff --git a/packages/content/api/content-sdk-content.api.md b/packages/content/api/content-sdk-content.api.md index 6015d42826..b4fd5c84ad 100644 --- a/packages/content/api/content-sdk-content.api.md +++ b/packages/content/api/content-sdk-content.api.md @@ -610,6 +610,9 @@ export function getPersonalizedRewrite(pathname: string, variantIds: string[]): // @public export function getPersonalizedRewriteData(pathname: string): PersonalizedRewriteData; +// @public +export function getRenderingParamString(value: unknown): string | undefined; + // @public const getRequiredParams: (qs: { [key: string]: string | undefined; diff --git a/packages/content/src/layout/index.ts b/packages/content/src/layout/index.ts index 2c0d6b154d..7e33f17ef0 100644 --- a/packages/content/src/layout/index.ts +++ b/packages/content/src/layout/index.ts @@ -23,6 +23,7 @@ export { export { getFieldValue, + getRenderingParamString, getChildPlaceholder, isFieldValueEmpty, isDynamicPlaceholder, diff --git a/packages/content/src/layout/themes.test.ts b/packages/content/src/layout/themes.test.ts index 04d5a12a93..182ec3398e 100644 --- a/packages/content/src/layout/themes.test.ts +++ b/packages/content/src/layout/themes.test.ts @@ -420,6 +420,166 @@ describe('themes', () => { })) ); }); + + // Layout Service may return Styles/CSSStyles/LibraryId params as objects + // rather than strings. Traversal must not crash on non-string values. + describe('non-string params (DetailedRenderingParams)', () => { + const detailedStylesObject = { + 'Allowed Renderings': [], + IsVerifiedStyle: { value: false }, + Value: { value: 'White-Background' }, + Icon: { value: '' }, + }; + + it('should not throw when params.Styles is an object', () => { + const run = () => + getDesignLibraryStylesheetLinks( + setBasicLayoutData(({ + componentName: 'styled', + params: { Styles: detailedStylesObject }, + } as unknown) as ComponentRendering), + sitecoreEdgeContextId + ); + + expect(run).to.not.throw(); + expect(run()).to.deep.equal([]); + }); + + it('should not throw when params.CSSStyles is an object', () => { + const run = () => + getDesignLibraryStylesheetLinks( + setBasicLayoutData(({ + componentName: 'styled', + params: { CSSStyles: detailedStylesObject }, + } as unknown) as ComponentRendering), + sitecoreEdgeContextId + ); + + expect(run).to.not.throw(); + expect(run()).to.deep.equal([]); + }); + + it('should not throw when params.LibraryId is an object', () => { + const run = () => + getDesignLibraryStylesheetLinks( + setBasicLayoutData(({ + componentName: 'styled', + params: { LibraryId: { Value: { value: 'bar' } } }, + } as unknown) as ComponentRendering), + sitecoreEdgeContextId + ); + + expect(run).to.not.throw(); + expect(run()).to.deep.equal([ + { href: getStylesheetUrl('bar', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + }); + + it('should resolve library id from object Styles Value.value', () => { + expect( + getDesignLibraryStylesheetLinks( + setBasicLayoutData(({ + componentName: 'styled', + params: { + Styles: { + Value: { value: '-library--foo White-Background' }, + }, + }, + } as unknown) as ComponentRendering), + sitecoreEdgeContextId + ) + ).to.deep.equal([ + { href: getStylesheetUrl('foo', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + }); + + it('should resolve library id from JSON string Styles param', () => { + expect( + getDesignLibraryStylesheetLinks( + setBasicLayoutData(({ + componentName: 'styled', + params: { + Styles: '{"Value":{"value":"-library--foo"}}', + }, + } as unknown) as ComponentRendering), + sitecoreEdgeContextId + ) + ).to.deep.equal([ + { href: getStylesheetUrl('foo', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + }); + + it('should fall back to fields when params are objects', () => { + expect( + getDesignLibraryStylesheetLinks( + setBasicLayoutData(({ + componentName: 'styled', + params: { Styles: detailedStylesObject }, + fields: { + LibraryId: { + value: 'bar', + }, + }, + } as unknown) as ComponentRendering), + sitecoreEdgeContextId + ) + ).to.deep.equal([ + { href: getStylesheetUrl('bar', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + }); + + it('should not throw when traversing nested placeholders with object params', () => { + const layoutData = { + sitecore: { + context: {}, + route: { + name: 'home', + placeholders: { + main: [ + { + componentName: 'header', + params: { Styles: detailedStylesObject }, + placeholders: { + content: [ + { + componentName: 'body', + params: { + CSSStyles: detailedStylesObject, + LibraryId: { IsVerifiedStyle: { value: false } }, + }, + }, + ], + }, + }, + ], + }, + }, + }, + }; + + const run = () => getDesignLibraryStylesheetLinks(layoutData, sitecoreEdgeContextId); + + expect(run).to.not.throw(); + expect(run()).to.deep.equal([]); + }); + + it('should still resolve library id from string CSSStyles when Styles param is an object', () => { + expect( + getDesignLibraryStylesheetLinks( + setBasicLayoutData(({ + componentName: 'styled', + params: { + CSSStyles: '-library--foo', + Styles: detailedStylesObject, + }, + } as unknown) as ComponentRendering), + sitecoreEdgeContextId + ) + ).to.deep.equal([ + { href: getStylesheetUrl('foo', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + }); + }); }); describe('getStylesheetUrl', () => { diff --git a/packages/content/src/layout/themes.ts b/packages/content/src/layout/themes.ts index 81db7a78b0..12f8938820 100644 --- a/packages/content/src/layout/themes.ts +++ b/packages/content/src/layout/themes.ts @@ -1,6 +1,12 @@ import { constants } from '@sitecore-content-sdk/core'; import { normalizeUrl } from '@sitecore-content-sdk/core/tools'; -import { ComponentRendering, LayoutServiceData, RouteData, getFieldValue } from '.'; +import { + ComponentRendering, + LayoutServiceData, + RouteData, + getFieldValue, + getRenderingParamString, +} from '.'; import { HTMLLink } from '../models'; /** @@ -60,19 +66,29 @@ const traversePlaceholder = (components: ComponentRendering[], ids: Set) const traverseComponent = (component: RouteData | ComponentRendering, ids: Set) => { let libraryId: string | undefined = undefined; if ('params' in component && component.params) { + const cssStylesParam = getRenderingParamString(component.params.CSSStyles); + const stylesParam = getRenderingParamString(component.params.Styles); + const libraryIdParam = getRenderingParamString(component.params.LibraryId); // LibraryID in css class name takes precedence over LibraryId attribute libraryId = - component.params.CSSStyles?.match(STYLES_LIBRARY_ID_REGEX)?.[1] || - component.params.Styles?.match(STYLES_LIBRARY_ID_REGEX)?.[1] || - component.params.LibraryId || + cssStylesParam?.match(STYLES_LIBRARY_ID_REGEX)?.[1] || + stylesParam?.match(STYLES_LIBRARY_ID_REGEX)?.[1] || + libraryIdParam || undefined; } // if params are empty we try to fall back to data source if (!libraryId && 'fields' in component && component.fields) { + const cssStylesField = getRenderingParamString( + getFieldValue(component.fields, 'CSSStyles', '') + ); + const stylesField = getRenderingParamString(getFieldValue(component.fields, 'Styles', '')); + const libraryIdField = getRenderingParamString( + getFieldValue(component.fields, 'LibraryId', '') + ); libraryId = - getFieldValue(component.fields, 'CSSStyles', '').match(STYLES_LIBRARY_ID_REGEX)?.[1] || - getFieldValue(component.fields, 'Styles', '').match(STYLES_LIBRARY_ID_REGEX)?.[1] || - getFieldValue(component.fields, 'LibraryId', '') || + cssStylesField?.match(STYLES_LIBRARY_ID_REGEX)?.[1] || + stylesField?.match(STYLES_LIBRARY_ID_REGEX)?.[1] || + libraryIdField || undefined; } diff --git a/packages/content/src/layout/utils.test.ts b/packages/content/src/layout/utils.test.ts index fe90bfe327..61738e1e0c 100644 --- a/packages/content/src/layout/utils.test.ts +++ b/packages/content/src/layout/utils.test.ts @@ -3,6 +3,7 @@ import { expect } from 'chai'; import { ComponentRendering } from '../../layout'; import { getFieldValue, + getRenderingParamString, getChildPlaceholder, isFieldValueEmpty, isDynamicPlaceholder, @@ -11,6 +12,42 @@ import { } from './utils'; describe('core layout utils', () => { + describe('getRenderingParamString', () => { + it('should return plain string params unchanged', () => { + expect(getRenderingParamString('col-lg-6')).to.equal('col-lg-6'); + expect(getRenderingParamString('-library--foo')).to.equal('-library--foo'); + }); + + it('should extract Value.value from DetailedRenderingParams objects', () => { + expect( + getRenderingParamString({ + 'Allowed Renderings': [], + IsVerifiedStyle: { value: false }, + Value: { value: 'White-Background' }, + Icon: { value: '' }, + }) + ).to.equal('White-Background'); + }); + + it('should extract value from simple object wrappers', () => { + expect(getRenderingParamString({ value: 'bar' })).to.equal('bar'); + }); + + it('should extract Value.value from JSON string params', () => { + expect( + getRenderingParamString('{"Value":{"value":"White-Background"},"IsVerifiedStyle":{"value":false}}') + ).to.equal('White-Background'); + }); + + it('should return undefined for unextractable values', () => { + expect(getRenderingParamString(undefined)).to.be.undefined; + expect(getRenderingParamString(null)).to.be.undefined; + expect(getRenderingParamString({})).to.be.undefined; + expect(getRenderingParamString({ Value: { value: 42 } })).to.be.undefined; + expect(getRenderingParamString('{"foo":"bar"}')).to.be.undefined; + }); + }); + describe('getFieldValue', () => { const fields = { crop: { diff --git a/packages/content/src/layout/utils.ts b/packages/content/src/layout/utils.ts index 1a55c80890..c1cd743415 100644 --- a/packages/content/src/layout/utils.ts +++ b/packages/content/src/layout/utils.ts @@ -1,6 +1,67 @@ /* eslint-disable no-redeclare */ import { ComponentRendering, ComponentFields, Field, GenericFieldValue } from './models'; +const extractDetailedRenderingParamValue = (value: Record): string | undefined => { + const detailedValue = value.Value; + if ( + detailedValue && + typeof detailedValue === 'object' && + detailedValue !== null && + 'value' in detailedValue + ) { + const nestedValue = (detailedValue as { value: unknown }).value; + if (typeof nestedValue === 'string') { + return nestedValue; + } + } + + if (typeof value.value === 'string') { + return value.value; + } + + return undefined; +}; + +/** + * Normalizes a rendering param value to a string. + * Layout Service may return DetailedRenderingParams as objects or JSON strings + * instead of plain strings (e.g. Styles, CSSStyles, GridParameters). + * @param {unknown} value rendering param value + * @returns {string | undefined} normalized string value, or undefined when not extractable + * @public + */ +export function getRenderingParamString(value: unknown): string | undefined { + if (value === null || value === undefined) { + return undefined; + } + + if (typeof value === 'string') { + if (value.startsWith('{')) { + try { + const parsed: unknown = JSON.parse(value); + if (parsed && typeof parsed === 'object') { + const extracted = extractDetailedRenderingParamValue(parsed as Record); + if (extracted !== undefined) { + return extracted; + } + + return undefined; + } + } catch { + // Not JSON — treat as a plain string param value. + } + } + + return value; + } + + if (typeof value === 'object') { + return extractDetailedRenderingParamValue(value as Record); + } + + return undefined; +} + /** * Safely extracts a field value from a rendering or fields object. * Null will be returned if the field is not defined. diff --git a/packages/react/src/components/Placeholder/placeholder-utils.test.tsx b/packages/react/src/components/Placeholder/placeholder-utils.test.tsx index 0b44218f18..29fd140945 100644 --- a/packages/react/src/components/Placeholder/placeholder-utils.test.tsx +++ b/packages/react/src/components/Placeholder/placeholder-utils.test.tsx @@ -150,7 +150,7 @@ describe('placeholder-utils', () => { const result = getSXAParams(rendering); expect(result).to.deep.equal({ - styles: 'col-lg-8 ', + styles: 'col-lg-8', }); }); @@ -167,7 +167,7 @@ describe('placeholder-utils', () => { const result = getSXAParams(rendering); expect(result).to.deep.equal({ - styles: ' custom-styles', + styles: 'custom-styles', }); }); @@ -181,6 +181,57 @@ describe('placeholder-utils', () => { expect(result).to.deep.equal({ styles: '' }); }); + + it('should extract styles from DetailedRenderingParams object', () => { + const rendering = ({ + componentName: 'TestComponent', + uid: 'test-uid', + params: { + Styles: { + Value: { value: 'White-Background' }, + }, + }, + } as unknown) as ComponentRendering; + + const result = getSXAParams(rendering); + + expect(result).to.deep.equal({ + styles: 'White-Background', + }); + }); + + it('should combine object GridParameters and Styles params', () => { + const rendering = ({ + componentName: 'TestComponent', + uid: 'test-uid', + params: { + GridParameters: { Value: { value: 'col-lg-6' } }, + Styles: { Value: { value: 'White-Background' } }, + }, + } as unknown) as ComponentRendering; + + const result = getSXAParams(rendering); + + expect(result).to.deep.equal({ + styles: 'col-lg-6 White-Background', + }); + }); + + it('should extract styles from JSON string DetailedRenderingParams', () => { + const rendering: ComponentRendering = { + componentName: 'TestComponent', + uid: 'test-uid', + params: { + Styles: '{"Value":{"value":"White-Background"}}', + }, + }; + + const result = getSXAParams(rendering); + + expect(result).to.deep.equal({ + styles: 'White-Background', + }); + }); }); describe('getChildComponentProps', () => { diff --git a/packages/react/src/components/Placeholder/placeholder-utils.tsx b/packages/react/src/components/Placeholder/placeholder-utils.tsx index 3d52324e9c..ae3bcb8310 100644 --- a/packages/react/src/components/Placeholder/placeholder-utils.tsx +++ b/packages/react/src/components/Placeholder/placeholder-utils.tsx @@ -6,6 +6,7 @@ import { RouteData, isDynamicPlaceholder, getDynamicPlaceholderPattern, + getRenderingParamString, } from '@sitecore-content-sdk/content/layout'; import { HIDDEN_RENDERING_NAME } from '@sitecore-content-sdk/content'; import { HiddenRendering } from '../HiddenRendering'; @@ -81,16 +82,18 @@ export const getPlaceholderRenderings = ( * @param {ComponentRendering} rendering rendering object * @returns {object} converted SXA params */ -export const getSXAParams = (rendering: ComponentRendering) => { +export const getSXAParams = (rendering: ComponentRendering): { styles: string } | undefined => { if (!rendering.params) return { styles: '' }; - const { GridParameters, Styles } = rendering.params; + const gridParameters = getRenderingParamString(rendering.params.GridParameters) ?? ''; + const styles = getRenderingParamString(rendering.params.Styles) ?? ''; + const combinedStyles = [gridParameters, styles].filter(Boolean).join(' '); - return ( - (GridParameters || Styles) && { - styles: `${GridParameters || ''} ${Styles || ''}`, - } - ); + if (!combinedStyles) { + return undefined; + } + + return { styles: combinedStyles }; }; /**