diff --git a/packages/optimizely-cms-sdk/src/graph/__test__/createQueryDAM.test.ts b/packages/optimizely-cms-sdk/src/graph/__test__/createQueryDAM.test.ts new file mode 100644 index 00000000..60b5130d --- /dev/null +++ b/packages/optimizely-cms-sdk/src/graph/__test__/createQueryDAM.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, test } from 'vitest'; +import { + createFragment, + createSingleContentQuery, + createMultipleContentQuery, +} from '../createQuery.js'; +import { contentType, initContentTypeRegistry } from '../../model/index.js'; + +describe('createFragment() with damEnabled for contentReference properties', () => { + test('damEnabled = false should not include ContentReferenceItem fragment', async () => { + const ct1 = contentType({ + key: 'ct1', + baseType: '_page', + properties: { + image: { type: 'contentReference' }, + }, + }); + initContentTypeRegistry([ct1]); + + // DAM disabled + const result = await createFragment('ct1', new Set(), '', true, false); + + // Should not include ContentReferenceItem fragments + expect(result.some((line) => line.includes('PublicImageAsset'))).toBe( + false + ); + expect(result.some((line) => line.includes('PublicVideoAsset'))).toBe( + false + ); + expect(result.some((line) => line.includes('PublicRawFileAsset'))).toBe( + false + ); + expect(result.some((line) => line.includes('ContentReferenceItem'))).toBe( + false + ); + + // Should only include key and url + expect(result).toMatchInlineSnapshot(` + [ + "fragment MediaMetadata on MediaMetadata { mimeType thumbnail content }", + "fragment ItemMetadata on ItemMetadata { changeset displayOption }", + "fragment InstanceMetadata on InstanceMetadata { changeset locales expired container owner routeSegment lastModifiedBy path createdBy }", + "fragment ContentUrl on ContentUrl { type default hierarchical internal graph base }", + "fragment IContentMetadata on IContentMetadata { key locale fallbackForLocale version displayName url {...ContentUrl} types published status created lastModified sortOrder variation ...MediaMetadata ...ItemMetadata ...InstanceMetadata }", + "fragment _IContent on _IContent { _id _metadata {...IContentMetadata} }", + "fragment ct1 on ct1 { __typename image { key url { ...ContentUrl } } ..._IContent }", + ] + `); + }); + + test('damEnabled = true should include ContentReferenceItem fragment', async () => { + const ct1 = contentType({ + key: 'ct1', + baseType: '_page', + properties: { + image: { type: 'contentReference' }, + }, + }); + initContentTypeRegistry([ct1]); + + // DAM enabled + const result = await createFragment('ct1', new Set(), '', true, true); + + // Should include all ContentReferenceItem fragments + expect(result.some((line) => line.includes('PublicImageAsset'))).toBe(true); + expect(result.some((line) => line.includes('PublicVideoAsset'))).toBe(true); + expect(result.some((line) => line.includes('PublicRawFileAsset'))).toBe( + true + ); + expect(result.some((line) => line.includes('ContentReferenceItem'))).toBe( + true + ); + + expect(result).toMatchInlineSnapshot(` + [ + "fragment MediaMetadata on MediaMetadata { mimeType thumbnail content }", + "fragment ItemMetadata on ItemMetadata { changeset displayOption }", + "fragment InstanceMetadata on InstanceMetadata { changeset locales expired container owner routeSegment lastModifiedBy path createdBy }", + "fragment ContentUrl on ContentUrl { type default hierarchical internal graph base }", + "fragment IContentMetadata on IContentMetadata { key locale fallbackForLocale version displayName url {...ContentUrl} types published status created lastModified sortOrder variation ...MediaMetadata ...ItemMetadata ...InstanceMetadata }", + "fragment _IContent on _IContent { _id _metadata {...IContentMetadata} }", + "fragment PublicImageAsset on cmp_PublicImageAsset { Url Title AltText Description Renditions { Id Name Url Width Height } FocalPoint { X Y } Tags { Guid Name } }", + "fragment PublicVideoAsset on cmp_PublicVideoAsset { Url Title AltText Description Renditions { Id Name Url Width Height } Tags { Guid Name } }", + "fragment PublicRawFileAsset on cmp_PublicRawFileAsset { Url Title Description Tags { Guid Name } }", + "fragment ContentReferenceItem on ContentReference { item { ...PublicImageAsset ...PublicVideoAsset ...PublicRawFileAsset } }", + "fragment ct1 on ct1 { __typename image { key url { ...ContentUrl } ...ContentReferenceItem } ..._IContent }", + ] + `); + }); + + test('damEnabled = false with array of contentReference', async () => { + const ct1 = contentType({ + key: 'ct1', + baseType: '_page', + properties: { + images: { type: 'array', items: { type: 'contentReference' } }, + }, + }); + initContentTypeRegistry([ct1]); + + // DAM disabled + const result = await createFragment('ct1', new Set(), '', true, false); + + expect(result.some((line) => line.includes('ContentReferenceItem'))).toBe( + false + ); + expect(result).toMatchInlineSnapshot(` + [ + "fragment MediaMetadata on MediaMetadata { mimeType thumbnail content }", + "fragment ItemMetadata on ItemMetadata { changeset displayOption }", + "fragment InstanceMetadata on InstanceMetadata { changeset locales expired container owner routeSegment lastModifiedBy path createdBy }", + "fragment ContentUrl on ContentUrl { type default hierarchical internal graph base }", + "fragment IContentMetadata on IContentMetadata { key locale fallbackForLocale version displayName url {...ContentUrl} types published status created lastModified sortOrder variation ...MediaMetadata ...ItemMetadata ...InstanceMetadata }", + "fragment _IContent on _IContent { _id _metadata {...IContentMetadata} }", + "fragment ct1 on ct1 { __typename images { key url { ...ContentUrl } } ..._IContent }", + ] + `); + }); + + test('damEnabled = true with array of contentReference', async () => { + const ct1 = contentType({ + key: 'ct1', + baseType: '_page', + properties: { + images: { type: 'array', items: { type: 'contentReference' } }, + }, + }); + initContentTypeRegistry([ct1]); + + const result = await createFragment('ct1', new Set(), '', true, true); + + expect(result.some((line) => line.includes('ContentReferenceItem'))).toBe( + true + ); + expect(result).toMatchInlineSnapshot(` + [ + "fragment MediaMetadata on MediaMetadata { mimeType thumbnail content }", + "fragment ItemMetadata on ItemMetadata { changeset displayOption }", + "fragment InstanceMetadata on InstanceMetadata { changeset locales expired container owner routeSegment lastModifiedBy path createdBy }", + "fragment ContentUrl on ContentUrl { type default hierarchical internal graph base }", + "fragment IContentMetadata on IContentMetadata { key locale fallbackForLocale version displayName url {...ContentUrl} types published status created lastModified sortOrder variation ...MediaMetadata ...ItemMetadata ...InstanceMetadata }", + "fragment _IContent on _IContent { _id _metadata {...IContentMetadata} }", + "fragment PublicImageAsset on cmp_PublicImageAsset { Url Title AltText Description Renditions { Id Name Url Width Height } FocalPoint { X Y } Tags { Guid Name } }", + "fragment PublicVideoAsset on cmp_PublicVideoAsset { Url Title AltText Description Renditions { Id Name Url Width Height } Tags { Guid Name } }", + "fragment PublicRawFileAsset on cmp_PublicRawFileAsset { Url Title Description Tags { Guid Name } }", + "fragment ContentReferenceItem on ContentReference { item { ...PublicImageAsset ...PublicVideoAsset ...PublicRawFileAsset } }", + "fragment ct1 on ct1 { __typename images { key url { ...ContentUrl } ...ContentReferenceItem } ..._IContent }", + ] + `); + }); + + test('damEnabled with nested component containing contentReference', async () => { + const ctBlock = contentType({ + key: 'ctBlock', + baseType: '_component', + properties: { + image: { type: 'contentReference' }, + }, + }); + const ct1 = contentType({ + key: 'ct1', + baseType: '_page', + properties: { + block: { type: 'component', contentType: ctBlock }, + }, + }); + initContentTypeRegistry([ct1, ctBlock]); + + // Test with damEnabled = false + const resultDisabled = await createFragment( + 'ct1', + new Set(), + '', + true, + false + ); + expect( + resultDisabled.some((line) => line.includes('ContentReferenceItem')) + ).toBe(false); + + // Test with damEnabled = true + const resultEnabled = await createFragment( + 'ct1', + new Set(), + '', + true, + true + ); + expect( + resultEnabled.some((line) => line.includes('ContentReferenceItem')) + ).toBe(true); + expect(resultEnabled).toMatchInlineSnapshot(` + [ + "fragment MediaMetadata on MediaMetadata { mimeType thumbnail content }", + "fragment ItemMetadata on ItemMetadata { changeset displayOption }", + "fragment InstanceMetadata on InstanceMetadata { changeset locales expired container owner routeSegment lastModifiedBy path createdBy }", + "fragment ContentUrl on ContentUrl { type default hierarchical internal graph base }", + "fragment IContentMetadata on IContentMetadata { key locale fallbackForLocale version displayName url {...ContentUrl} types published status created lastModified sortOrder variation ...MediaMetadata ...ItemMetadata ...InstanceMetadata }", + "fragment _IContent on _IContent { _id _metadata {...IContentMetadata} }", + "fragment PublicImageAsset on cmp_PublicImageAsset { Url Title AltText Description Renditions { Id Name Url Width Height } FocalPoint { X Y } Tags { Guid Name } }", + "fragment PublicVideoAsset on cmp_PublicVideoAsset { Url Title AltText Description Renditions { Id Name Url Width Height } Tags { Guid Name } }", + "fragment PublicRawFileAsset on cmp_PublicRawFileAsset { Url Title Description Tags { Guid Name } }", + "fragment ContentReferenceItem on ContentReference { item { ...PublicImageAsset ...PublicVideoAsset ...PublicRawFileAsset } }", + "fragment ctBlockProperty on ctBlockProperty { __typename image { key url { ...ContentUrl } ...ContentReferenceItem } }", + "fragment ct1 on ct1 { __typename ct1__block:block { ...ctBlockProperty } ..._IContent }", + ] + `); + }); + + test('damEnabled with content property containing contentReference', async () => { + const ctRef = contentType({ + key: 'ctRef', + baseType: '_component', + properties: { + image: { type: 'contentReference' }, + }, + }); + const ct1 = contentType({ + key: 'ct1', + baseType: '_page', + properties: { + content: { type: 'content', allowedTypes: [ctRef] }, + }, + }); + initContentTypeRegistry([ct1, ctRef]); + + // Test with damEnabled = true + const result = await createFragment('ct1', new Set(), '', true, true); + expect(result.some((line) => line.includes('ContentReferenceItem'))).toBe( + true + ); + expect(result).toMatchInlineSnapshot(` + [ + "fragment MediaMetadata on MediaMetadata { mimeType thumbnail content }", + "fragment ItemMetadata on ItemMetadata { changeset displayOption }", + "fragment InstanceMetadata on InstanceMetadata { changeset locales expired container owner routeSegment lastModifiedBy path createdBy }", + "fragment ContentUrl on ContentUrl { type default hierarchical internal graph base }", + "fragment IContentMetadata on IContentMetadata { key locale fallbackForLocale version displayName url {...ContentUrl} types published status created lastModified sortOrder variation ...MediaMetadata ...ItemMetadata ...InstanceMetadata }", + "fragment _IContent on _IContent { _id _metadata {...IContentMetadata} }", + "fragment PublicImageAsset on cmp_PublicImageAsset { Url Title AltText Description Renditions { Id Name Url Width Height } FocalPoint { X Y } Tags { Guid Name } }", + "fragment PublicVideoAsset on cmp_PublicVideoAsset { Url Title AltText Description Renditions { Id Name Url Width Height } Tags { Guid Name } }", + "fragment PublicRawFileAsset on cmp_PublicRawFileAsset { Url Title Description Tags { Guid Name } }", + "fragment ContentReferenceItem on ContentReference { item { ...PublicImageAsset ...PublicVideoAsset ...PublicRawFileAsset } }", + "fragment ctRef on ctRef { __typename image { key url { ...ContentUrl } ...ContentReferenceItem } ..._IContent }", + "fragment ct1 on ct1 { __typename ct1__content:content { __typename ...ctRef } ..._IContent }", + ] + `); + }); +}); + +describe('createSingleContentQuery() with damEnabled', () => { + test('damEnabled = false should not include DAM fragments', async () => { + const ct1 = contentType({ + key: 'ct1', + baseType: '_page', + properties: { + image: { type: 'contentReference' }, + }, + }); + initContentTypeRegistry([ct1]); + + const query = await createSingleContentQuery('ct1', false); + + expect(query.includes('PublicImageAsset')).toBe(false); + expect(query.includes('ContentReferenceItem')).toBe(false); + expect(query).toContain('image { key url { ...ContentUrl } }'); + }); + + test('damEnabled = true should include DAM fragments', async () => { + const ct1 = contentType({ + key: 'ct1', + baseType: '_page', + properties: { + image: { type: 'contentReference' }, + }, + }); + initContentTypeRegistry([ct1]); + + const query = await createSingleContentQuery('ct1', true); + + expect(query.includes('PublicImageAsset')).toBe(true); + expect(query.includes('PublicVideoAsset')).toBe(true); + expect(query.includes('PublicRawFileAsset')).toBe(true); + expect(query.includes('ContentReferenceItem')).toBe(true); + expect(query).toContain( + 'image { key url { ...ContentUrl } ...ContentReferenceItem }' + ); + }); +}); + +describe('createMultipleContentQuery() with damEnabled', () => { + test('damEnabled = false should not include DAM fragments', async () => { + const ct1 = contentType({ + key: 'ct1', + baseType: '_page', + properties: { + image: { type: 'contentReference' }, + }, + }); + initContentTypeRegistry([ct1]); + + const query = await createMultipleContentQuery('ct1', false); + + expect(query.includes('PublicImageAsset')).toBe(false); + expect(query.includes('ContentReferenceItem')).toBe(false); + }); + + test('damEnabled = true should include DAM fragments', async () => { + const ct1 = contentType({ + key: 'ct1', + baseType: '_page', + properties: { + image: { type: 'contentReference' }, + }, + }); + initContentTypeRegistry([ct1]); + + const query = await createMultipleContentQuery('ct1', true); + + expect(query.includes('PublicImageAsset')).toBe(true); + expect(query.includes('PublicVideoAsset')).toBe(true); + expect(query.includes('PublicRawFileAsset')).toBe(true); + expect(query.includes('ContentReferenceItem')).toBe(true); + }); +}); diff --git a/packages/optimizely-cms-sdk/src/graph/createQuery.ts b/packages/optimizely-cms-sdk/src/graph/createQuery.ts index e83018e6..a7b6d93e 100644 --- a/packages/optimizely-cms-sdk/src/graph/createQuery.ts +++ b/packages/optimizely-cms-sdk/src/graph/createQuery.ts @@ -68,14 +68,16 @@ function convertProperty( property: AnyProperty, rootName: string, suffix: string, - visited: Set + visited: Set, + damEnabled: boolean = false ): { fields: string[]; extraFragments: string[] } { const result = convertPropertyField( name, property, rootName, suffix, - visited + visited, + damEnabled ); // logs warnings if the fragment generation causes potential issues @@ -101,7 +103,8 @@ function convertPropertyField( property: AnyProperty, rootName: string, suffix: string, - visited: Set + visited: Set, + damEnabled: boolean = false ): { fields: string[]; extraFragments: string[] } { const fields: string[] = []; const subfields: string[] = []; @@ -111,7 +114,9 @@ function convertPropertyField( if (property.type === 'component') { const key = property.contentType.key; const fragmentName = `${key}Property`; - extraFragments.push(...createFragment(key, visited, 'Property', false)); + extraFragments.push( + ...createFragment(key, visited, 'Property', false, damEnabled) + ); fields.push(`${nameInFragment} { ...${fragmentName} }`); } else if (property.type === 'content') { const allowed = resolveAllowedTypes( @@ -125,7 +130,9 @@ function convertPropertyField( if (key === '_self') { key = rootName; } - extraFragments.push(...createFragment(key, visited)); + extraFragments.push( + ...createFragment(key, visited, '', true, damEnabled) + ); subfields.push(`...${key}`); } @@ -141,9 +148,17 @@ function convertPropertyField( fields.push(`${nameInFragment} { text title target url { ...ContentUrl }}`); } else if (property.type === 'contentReference') { extraFragments.push(CONTENT_URL_FRAGMENT); - fields.push(`${nameInFragment} { key url { ...ContentUrl }}`); + const itemFragment = damEnabled ? ' ...ContentReferenceItem' : ''; + fields.push(`${name} { key url { ...ContentUrl }${itemFragment} }`); } else if (property.type === 'array') { - const f = convertProperty(name, property.items, rootName, suffix, visited); + const f = convertProperty( + name, + property.items, + rootName, + suffix, + visited, + damEnabled + ); fields.push(...f.fields); extraFragments.push(...f.extraFragments); } else { @@ -161,7 +176,10 @@ function convertPropertyField( * @param visited - Set of fragment names already visited to avoid cycles. * @returns A list of GraphQL fragment strings. */ -function createExperienceFragments(visited: Set): string[] { +function createExperienceFragments( + visited: Set, + damEnabled: boolean = false +): string[] { // Fixed fragments for all experiences const fixedFragments = [ 'fragment _IExperience on _IExperience { composition {...ICompositionNode }}', @@ -183,7 +201,7 @@ function createExperienceFragments(visited: Set): string[] { // Get the required fragments const extraFragments = experienceNodes .filter((n) => !visited.has(n)) - .flatMap((n) => createFragment(n, visited)); + .flatMap((n) => createFragment(n, visited, '', true, damEnabled)); const nodeNames = experienceNodes.map((n) => `...${n}`).join(' '); const componentFragment = `fragment _IComponent on _IComponent { __typename ${nodeNames} }`; @@ -201,7 +219,8 @@ export function createFragment( contentTypeName: string, visited: Set = new Set(), // shared across recursion suffix: string = '', - includeBaseFragments: boolean = true + includeBaseFragments: boolean = true, + damEnabled: boolean = false ): string[] { const fragmentName = `${contentTypeName}${suffix}`; if (visited.has(fragmentName)) return []; // cyclic ref guard @@ -212,9 +231,9 @@ export function createFragment( const fields: string[] = ['__typename']; const extraFragments: string[] = []; - // Built‑in CMS baseTypes ("_image", "_video", "_media" etc.) + // Built‑in CMS baseTypes if (isBaseType(contentTypeName)) { - const { fields: f, extraFragments: e } = buildBaseTypeFragments(); + const { fields: f, extraFragments: e } = buildBaseTypeFragments(damEnabled); fields.push(...f); extraFragments.push(...e); } else { @@ -235,7 +254,8 @@ export function createFragment( prop, contentTypeName, suffix, - visited + visited, + damEnabled ); fields.push(...f); extraFragments.push(...e); @@ -243,14 +263,14 @@ export function createFragment( // Add fragments for the base type of the user-defined content type if (includeBaseFragments) { - const baseFragments = buildBaseTypeFragments(); + const baseFragments = buildBaseTypeFragments(damEnabled); extraFragments.unshift(...baseFragments.extraFragments); // maintain order fields.push(...baseFragments.fields); } if (ct.baseType === '_experience') { fields.push('..._IExperience'); - extraFragments.push(...createExperienceFragments(visited)); + extraFragments.push(...createExperienceFragments(visited, damEnabled)); } } @@ -272,8 +292,11 @@ export function createFragment( * @param contentType - The key of the content type to query. * @returns A string representing the GraphQL query. */ -export function createSingleContentQuery(contentType: string) { - const fragment = createFragment(contentType); +export function createSingleContentQuery( + contentType: string, + damEnabled: boolean = false +) { + const fragment = createFragment(contentType, new Set(), '', true, damEnabled); const fragmentName = fragment.length > 0 ? '...' + contentType : ''; return ` @@ -299,8 +322,11 @@ query GetContent($where: _ContentWhereInput, $variation: VariationInput) { * @param contentType - The key of the content type to query. * @returns A string representing the GraphQL query. */ -export function createMultipleContentQuery(contentType: string) { - const fragment = createFragment(contentType); +export function createMultipleContentQuery( + contentType: string, + damEnabled: boolean = false +) { + const fragment = createFragment(contentType, new Set(), '', true, damEnabled); const fragmentName = fragment.length > 0 ? '...' + contentType : ''; return ` diff --git a/packages/optimizely-cms-sdk/src/graph/index.ts b/packages/optimizely-cms-sdk/src/graph/index.ts index 2732158b..28c61bf5 100644 --- a/packages/optimizely-cms-sdk/src/graph/index.ts +++ b/packages/optimizely-cms-sdk/src/graph/index.ts @@ -20,6 +20,7 @@ import { type GraphOptions = { /** Graph instance URL. `https://cg.optimizely.com/content/v2` */ graphUrl?: string; + damEnabled?: boolean; }; export type PreviewParams = { @@ -218,10 +219,12 @@ function decorateWithContext(obj: any, params: PreviewParams): any { export class GraphClient { key: string; graphUrl: string; + damEnabled: boolean = false; constructor(key: string, options: GraphOptions = {}) { this.key = key; this.graphUrl = options.graphUrl ?? 'https://cg.optimizely.com/content/v2'; + this.damEnabled = options.damEnabled ?? false; } /** Perform a GraphQL query with variables */ @@ -342,7 +345,7 @@ export class GraphClient { return []; } - const query = createMultipleContentQuery(contentTypeName); + const query = createMultipleContentQuery(contentTypeName, this.damEnabled); const response = (await this.request(query, input)) as ItemsResponse; return response?._Content?.items.map(removeTypePrefix); @@ -428,7 +431,7 @@ export class GraphClient { { request: { variables: input, query: GET_CONTENT_METADATA_QUERY } } ); } - const query = createSingleContentQuery(contentTypeName); + const query = createSingleContentQuery(contentTypeName, this.damEnabled); const response = await this.request(query, input, params.preview_token); return decorateWithContext( diff --git a/packages/optimizely-cms-sdk/src/index.ts b/packages/optimizely-cms-sdk/src/index.ts index 1f8390b8..d3d1075a 100644 --- a/packages/optimizely-cms-sdk/src/index.ts +++ b/packages/optimizely-cms-sdk/src/index.ts @@ -27,3 +27,4 @@ export * as BuildConfig from './model/buildConfig.js'; export * as DisplayTemplates from './model/displayTemplates.js'; export * as Properties from './model/properties.js'; export { Infer } from './infer.js'; +export { damAssets } from './render/assets.js'; diff --git a/packages/optimizely-cms-sdk/src/infer.ts b/packages/optimizely-cms-sdk/src/infer.ts index 4f6005c3..1f0c8d13 100644 --- a/packages/optimizely-cms-sdk/src/infer.ts +++ b/packages/optimizely-cms-sdk/src/infer.ts @@ -28,6 +28,11 @@ import { SectionContentType, } from './model/contentTypes.js'; import { Node } from './components/richText/renderer.js'; +import { + PublicImageAsset, + PublicRawFileAsset, + PublicVideoAsset, +} from './model/assets.js'; /** Forces Intellisense to resolve types */ export type Prettify = { @@ -82,6 +87,17 @@ type InferredRichText = { json: { type: 'richText'; children: Node[] }; }; +/** Asset types that can be returned in ContentReference */ +export type ContentReferenceItem = + | PublicImageAsset + | PublicVideoAsset + | PublicRawFileAsset; + +export type InferredContentReference = { + url: InferredUrl; + item: ContentReferenceItem | null; +}; + /** Infers the Typescript type for each content type property */ // prettier-ignore export type InferFromProperty = @@ -95,7 +111,7 @@ export type InferFromProperty = : T extends LinkProperty ? { text: string | null, title: string | null, target: string | null, url: InferredUrl } : T extends IntegerProperty ? number : T extends FloatProperty ? number - : T extends ContentReferenceProperty ? { url: InferredUrl } + : T extends ContentReferenceProperty ? InferredContentReference : T extends ArrayProperty ? InferFromProperty[] : T extends ContentProperty ? {__typename: string, __viewname: string} : T extends ComponentProperty ? Infer diff --git a/packages/optimizely-cms-sdk/src/model/assets.ts b/packages/optimizely-cms-sdk/src/model/assets.ts new file mode 100644 index 00000000..44997614 --- /dev/null +++ b/packages/optimizely-cms-sdk/src/model/assets.ts @@ -0,0 +1,37 @@ +export type Renditions = { + Id: string | null; + Name: string | null; + Url: string | null; + Width: number | null; + Height: number | null; +}; + +export type Tags = { + Guid: string | null; + Name: string | null; +}; + +/** Common fields shared across all cmp asset types */ +type BaseAsset = { + Url: string | null; + Title: string | null; + Description: string | null; + Tags: Tags[] | null; +}; + +export type PublicImageAsset = BaseAsset & { + __typename: 'cmp_PublicImageAsset'; + AltText: string | null; + Renditions: Renditions[] | null; + FocalPoint: { X: number | null; Y: number | null } | null; +}; + +export type PublicVideoAsset = BaseAsset & { + __typename: 'cmp_PublicVideoAsset'; + AltText: string | null; + Renditions: Renditions[] | null; +}; + +export type PublicRawFileAsset = BaseAsset & { + __typename: 'cmp_PublicRawFileAsset'; +}; diff --git a/packages/optimizely-cms-sdk/src/react/__test__/assetsUtils.test.tsx b/packages/optimizely-cms-sdk/src/react/__test__/assetsUtils.test.tsx new file mode 100644 index 00000000..4a07d2aa --- /dev/null +++ b/packages/optimizely-cms-sdk/src/react/__test__/assetsUtils.test.tsx @@ -0,0 +1,363 @@ +import { describe, it, expect } from 'vitest'; +import { getPreviewUtils } from '../server.js'; +import { damAssets, getSrcset, getAlt } from '../../render/assets.js'; +import type { InferredContentReference } from '../../infer.js'; + +describe('getPreviewUtils', () => { + const mockRenditions = [ + { + Id: 'thumb', + Name: 'Thumbnail', + Url: 'https://assets.local-cms.com/0a2f4b27-4f15-4bb9-ba14-d69b49ae5b85/cmp_73d48db0-2abe-4a33-91f5-94d0ac5e85e5_thumbnail_100x100_63.jpg', + Width: 100, + Height: 100, + }, + { + Id: 'small', + Name: 'Small', + Url: 'https://assets.local-cms.com/0a2f4b27-4f15-4bb9-ba14-d69b49ae5b85/cmp_73d48db0-2abe-4a33-91f5-94d0ac5e85e5_small_256x256_63.jpg', + Width: 256, + Height: 256, + }, + { + Id: 'medium', + Name: 'Medium', + Url: 'https://assets.local-cms.com/0a2f4b27-4f15-4bb9-ba14-d69b49ae5b85/cmp_73d48db0-2abe-4a33-91f5-94d0ac5e85e5_medium_512x512_63.jpg', + Width: 512, + Height: 512, + }, + { + Id: 'medium2', + Name: 'Medium 2', + Url: 'https://assets.local-cms.com/0a2f4b27-4f15-4bb9-ba14-d69b49ae5b85/cmp_73d48db0-2abe-4a33-91f5-94d0ac5e85e5_medium_512x341_63.jpg', + Width: 512, + Height: 341, + }, + { + Id: 'large', + Name: 'Large', + Url: 'https://assets.local-cms.com/0a2f4b27-4f15-4bb9-ba14-d69b49ae5b85/cmp_73d48db0-2abe-4a33-91f5-94d0ac5e85e5_large_1920x1920_63.jpg', + Width: 1920, + Height: 1920, + }, + ]; + + const mockImageAsset: InferredContentReference = { + url: { + type: null, + default: + 'https://assets.local-cms.com/0a2f4b27-4f15-4bb9-ba14-d69b49ae5b85/cmp_73d48db0-2abe-4a33-91f5-94d0ac5e85e5.jpg', + hierarchical: null, + internal: null, + graph: null, + base: null, + }, + item: { + __typename: 'cmp_PublicImageAsset' as const, + Url: 'https://assets.local-cms.com/0a2f4b27-4f15-4bb9-ba14-d69b49ae5b85/cmp_73d48db0-2abe-4a33-91f5-94d0ac5e85e5.jpg', + Title: 'Harley-Davidson Touring Bike', + AltText: 'Harley-Davidson Touring Bike motorcycle', + Description: 'A beautiful Harley-Davidson motorcycle', + Renditions: mockRenditions, + FocalPoint: { X: 0.5, Y: 0.5 }, + Tags: [], + }, + }; + + describe('src()', () => { + it('should return empty string when not in preview mode', () => { + const utils = getPreviewUtils({ __typename: 'TestPage' }); + const result = utils.src(mockImageAsset); + + expect(result).toBe(''); + }); + + it('should append preview token to item URL', () => { + const utils = getPreviewUtils({ + __typename: 'TestPage', + __context: { edit: true, preview_token: 'test-token-123' }, + }); + const result = utils.src(mockImageAsset); + + // Should return the item.Url with preview token appended + expect(result).toBe( + 'https://assets.local-cms.com/0a2f4b27-4f15-4bb9-ba14-d69b49ae5b85/cmp_73d48db0-2abe-4a33-91f5-94d0ac5e85e5.jpg?preview_token=test-token-123' + ); + }); + + it('should append preview token to string URL', () => { + const utils = getPreviewUtils({ + __typename: 'TestPage', + __context: { edit: true, preview_token: 'test-token-123' }, + }); + const result = utils.src('https://example.com/image.jpg'); + + expect(result).toBe( + 'https://example.com/image.jpg?preview_token=test-token-123' + ); + }); + + it('should return empty string for string URL when not in preview mode', () => { + const utils = getPreviewUtils({ __typename: 'TestPage' }); + const result = utils.src('https://example.com/image.jpg'); + + expect(result).toBe(''); + }); + }); + + describe('getSrcset()', () => { + it('should generate srcset with unique widths from opti object', () => { + const opti = { __typename: 'TestPage', image: mockImageAsset }; + const result = getSrcset(opti, opti.image); + + expect(result).toBeDefined(); + // Should have 4 unique widths: 100, 256, 512, 1920 (duplicate 512 removed) + const widthMatches = result!.match(/\d+w/g); + expect(widthMatches).toHaveLength(4); + + expect(result).toContain('thumbnail_100x100_63.jpg 100w'); + expect(result).toContain('small_256x256_63.jpg 256w'); + expect(result).toContain('medium_512x512_63.jpg 512w'); + expect(result).toContain('large_1920x1920_63.jpg 1920w'); + }); + + it('should generate srcset directly from InferredContentReference', () => { + const opti = { __typename: 'TestPage' }; + const result = getSrcset(opti, mockImageAsset); + + expect(result).toBeDefined(); + // Should have 4 unique widths: 100, 256, 512, 1920 (duplicate 512 removed) + const widthMatches = result!.match(/\d+w/g); + expect(widthMatches).toHaveLength(4); + + expect(result).toContain('thumbnail_100x100_63.jpg 100w'); + expect(result).toContain('small_256x256_63.jpg 256w'); + expect(result).toContain('medium_512x512_63.jpg 512w'); + expect(result).toContain('large_1920x1920_63.jpg 1920w'); + }); + + it('should append preview token when passed in opti context', () => { + const opti = { + __typename: 'TestPage', + __context: { edit: true, preview_token: 'test-token-456' }, + }; + const result = getSrcset(opti, mockImageAsset); + + expect(result).toBeDefined(); + const entries = result!.split(', '); + expect( + entries.every((entry: string) => + entry.includes('preview_token=test-token-456') + ) + ).toBe(true); + }); + + it('should deduplicate renditions with same width', () => { + const opti = { __typename: 'TestPage', image: mockImageAsset }; + const result = getSrcset(opti, opti.image); + + expect(result).toBeDefined(); + // Width 512 appears twice in renditions but should only appear once in srcset + const width512Count = (result!.match(/512w/g) || []).length; + expect(width512Count).toBe(1); + }); + + it('should append preview token to renditions in preview mode from opti', () => { + const opti = { + __typename: 'TestPage', + image: mockImageAsset, + __context: { edit: true, preview_token: 'test-token-123' }, + }; + const result = getSrcset(opti, opti.image); + + expect(result).toBeDefined(); + const entries = result!.split(', '); + expect( + entries.every((entry: string) => + entry.includes('preview_token=test-token-123') + ) + ).toBe(true); + }); + + it('should return undefined for null ContentReference', () => { + const opti = { __typename: 'TestPage' }; + const result = getSrcset(opti, null); + expect(result).toBeUndefined(); + }); + + it('should return undefined for undefined ContentReference', () => { + const opti = { __typename: 'TestPage' }; + const result = getSrcset(opti, undefined); + expect(result).toBeUndefined(); + }); + + it('should return undefined for ContentReference without renditions', () => { + const noRenditionsAsset: InferredContentReference = { + ...mockImageAsset, + item: { + __typename: 'cmp_PublicImageAsset' as const, + Url: 'https://example.com/image.jpg', + Title: 'Test Image', + AltText: 'Test', + Description: 'Test description', + Renditions: [], + FocalPoint: null, + Tags: [], + }, + }; + + const opti = { __typename: 'TestPage' }; + const result = getSrcset(opti, noRenditionsAsset); + expect(result).toBeUndefined(); + }); + }); + + describe('getAlt()', () => { + it('should return AltText from image asset', () => { + const result = getAlt(mockImageAsset); + + expect(result).toBe('Harley-Davidson Touring Bike motorcycle'); + }); + + it('should use fallback when AltText is empty', () => { + const emptyAltAsset: InferredContentReference = { + ...mockImageAsset, + item: { + __typename: 'cmp_PublicImageAsset' as const, + Url: 'https://example.com/image.jpg', + Title: 'Test Image', + AltText: '', + Description: 'Test description', + Renditions: [], + FocalPoint: null, + Tags: [], + }, + }; + + expect(getAlt(emptyAltAsset, 'Fallback text')).toBe('Fallback text'); + }); + + it('should use fallback when AltText is null', () => { + const nullAltAsset: InferredContentReference = { + ...mockImageAsset, + item: { + __typename: 'cmp_PublicImageAsset' as const, + Url: 'https://example.com/image.jpg', + Title: 'Test Image', + AltText: null, + Description: 'Test description', + Renditions: [], + FocalPoint: null, + Tags: [], + }, + }; + + expect(getAlt(nullAltAsset, 'Fallback text')).toBe('Fallback text'); + }); + + it('should use fallback when input is null', () => { + expect(getAlt(null, 'Fallback text')).toBe('Fallback text'); + }); + + it('should return empty string when no fallback provided', () => { + expect(getAlt(null)).toBe(''); + }); + }); + + describe('damAssets()', () => { + it('should return scoped getSrcset and getAlt functions', () => { + const opti = { __typename: 'TestPage', image: mockImageAsset }; + const assets = damAssets(opti); + + expect(assets).toHaveProperty('getSrcset'); + expect(assets).toHaveProperty('getAlt'); + expect(typeof assets.getSrcset).toBe('function'); + expect(typeof assets.getAlt).toBe('function'); + }); + + it('should generate srcset using scoped getSrcset', () => { + const opti = { __typename: 'TestPage', image: mockImageAsset }; + const { getSrcset } = damAssets(opti); + + const result = getSrcset(opti.image); + + expect(result).toContain('thumbnail_100x100_63.jpg 100w'); + expect(result).toContain('small_256x256_63.jpg 256w'); + expect(result).toContain('medium_512x512_63.jpg 512w'); + expect(result).toContain('large_1920x1920_63.jpg 1920w'); + }); + + it('should handle preview tokens with scoped getSrcset', () => { + const opti = { + __typename: 'TestPage', + image: mockImageAsset, + __context: { edit: true, preview_token: 'test-token-123' }, + }; + const { getSrcset } = damAssets(opti); + + const result = getSrcset(opti.image); + + expect(result).toBeDefined(); + const entries = result!.split(', '); + expect( + entries.every((entry: string) => + entry.includes('preview_token=test-token-123') + ) + ).toBe(true); + }); + + it('should extract alt text using getAlt from damAssets', () => { + const opti = { __typename: 'TestPage', image: mockImageAsset }; + const { getAlt } = damAssets(opti); + + const result = getAlt(mockImageAsset); + + expect(result).toBe('Harley-Davidson Touring Bike motorcycle'); + }); + + it('should handle multiple properties with scoped getSrcset', () => { + const opti = { + __typename: 'TestPage', + heroImage: mockImageAsset, + thumbnail: mockImageAsset, + }; + const { getSrcset } = damAssets(opti); + + const heroResult = getSrcset(opti.heroImage); + const thumbResult = getSrcset(opti.thumbnail); + + expect(heroResult).toContain('thumbnail_100x100_63.jpg 100w'); + expect(thumbResult).toContain('thumbnail_100x100_63.jpg 100w'); + }); + + it('should work with fallback in getAlt', () => { + const emptyAltAsset: InferredContentReference = { + ...mockImageAsset, + item: { + __typename: 'cmp_PublicImageAsset' as const, + Url: 'https://example.com/image.jpg', + Title: 'Test Image', + AltText: '', + Description: 'Test description', + Renditions: [], + FocalPoint: null, + Tags: [], + }, + }; + + const opti = { __typename: 'TestPage', image: emptyAltAsset }; + const { getAlt } = damAssets(opti); + + expect(getAlt(emptyAltAsset, 'Custom fallback')).toBe('Custom fallback'); + }); + + it('should return undefined for missing properties', () => { + const opti = { __typename: 'TestPage', image: null }; + const { getSrcset } = damAssets(opti); + + const result = getSrcset(opti.image); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/optimizely-cms-sdk/src/react/server.tsx b/packages/optimizely-cms-sdk/src/react/server.tsx index 6a906ae6..a8041226 100644 --- a/packages/optimizely-cms-sdk/src/react/server.tsx +++ b/packages/optimizely-cms-sdk/src/react/server.tsx @@ -10,11 +10,13 @@ import { ExperienceComponentNode, DisplaySettingsType, ExperienceCompositionNode, + InferredContentReference, } from '../infer.js'; import { isComponentNode } from '../util/baseTypeUtil.js'; import { parseDisplaySettings } from '../model/displayTemplates.js'; import { getDisplayTemplateTag } from '../model/displayTemplateRegistry.js'; import { isDev } from '../util/environment.js'; +import { appendToken } from '../util/preview.js'; type ComponentType = React.ComponentType; @@ -328,13 +330,36 @@ export function getPreviewUtils(opti: OptimizelyComponentProps['opti']) { } }, - /** Appends the preview token to the provided image URL */ - src(url: string) { - if (opti.__context?.preview_token) { - const separator = url.includes('?') ? '&' : '?'; - return `${url}${separator}preview_token=${opti.__context.preview_token}`; + /** + * Appends preview token to a ContentReference's Image assets. + * Adds the preview token to the main URL and all rendition URLs when in preview mode. + * + * @param input - ContentReference from a DAM asset + * @returns ContentReference with preview tokens appended to all URLs, or the original if not in preview mode + * + * @example + * ```tsx + * const { src } = getPreviewUtils(opti); + * + * + * ``` + */ + src(input: InferredContentReference | string | null | undefined): string { + const previewToken = opti.__context?.preview_token; + + // if input is a ContentReference + if (typeof input === 'object' && previewToken && input?.item?.Url) { + return appendToken(input?.item?.Url, previewToken); } - return url; + + // if input is a string URL + if (typeof input === 'string' && previewToken) { + return appendToken(input, previewToken); + } + + return ''; }, }; } diff --git a/packages/optimizely-cms-sdk/src/render/assets.ts b/packages/optimizely-cms-sdk/src/render/assets.ts new file mode 100644 index 00000000..071bd8e7 --- /dev/null +++ b/packages/optimizely-cms-sdk/src/render/assets.ts @@ -0,0 +1,201 @@ +import type { InferredContentReference } from '../infer.js'; +import { appendToken } from '../util/preview.js'; + +/** + * Appends a preview token to a ContentReference's rendition URLs. + * Creates a new object with modified renditions to avoid mutation. + * + * @param input - ContentReference from a DAM asset + * @param previewToken - The preview token to append to rendition URLs + * @returns New ContentReference with preview tokens appended to rendition URLs + */ +function appendPreviewTokenToRenditions( + input: InferredContentReference | null | undefined, + previewToken: string | undefined +): InferredContentReference | null | undefined { + if (!input || !previewToken) return input; + + // Create a shallow copy of the input + const result = { ...input }; + + // Append preview token to all rendition URLs if they exist + if (result.item && 'Renditions' in result.item && result.item.Renditions) { + result.item = { + ...result.item, + Renditions: result.item.Renditions.map((r) => ({ + ...r, + Url: r.Url ? appendToken(r.Url, previewToken) : r.Url, + })), + }; + } + + return result; +} + +/** + * Creates a responsive srcset string from your image renditions. + * + * This handles all the messy details: + * - Removes duplicate widths if you have multiple renditions at the same size + * - Adds preview tokens automatically when in edit mode + * - Returns undefined if there's no image or no renditions (attribute won't be rendered) + * + * @param opti - Your content object with __context for preview tokens + * @param property - The image reference from your content (e.g., opti.image) + * @returns A srcset string like "url1 100w, url2 500w" or undefined if no renditions + * + * @example + * ```tsx + * import { damAssets } from '@optimizely/cms-sdk'; + * + * export default function MyComponent({ opti }) { + * const { getSrcset } = damAssets(opti); + * + * return ( + * + * ); + * } + * ``` + * + * @example + * Works with any image property: + * ```tsx + * const { getSrcset, getAlt } = damAssets(opti); + * Hero + * ``` + */ +export function getSrcset>( + opti: T & { __context?: { preview_token?: string } }, + property: InferredContentReference | null | undefined +): string | undefined { + const input = property; + const previewToken = opti?.__context?.preview_token; + + // Apply preview token to renditions if provided + const processedInput = previewToken + ? appendPreviewTokenToRenditions(input, previewToken) + : input; + + if (!processedInput?.item || !('Renditions' in processedInput.item)) + return undefined; + + const renditions = processedInput.item.Renditions; + if (!renditions || renditions.length === 0) return undefined; + + // Track seen widths to avoid duplicate width descriptors + const seenWidths = new Set(); + + const srcsetEntries = renditions + .filter((r) => { + if (!r.Url || !r.Width) return false; + // Skip if we've already seen this width + if (seenWidths.has(r.Width)) return false; + seenWidths.add(r.Width); + return true; + }) + .map((r) => `${r.Url!} ${r.Width}w`); + + return srcsetEntries.length > 0 ? srcsetEntries.join(', ') : undefined; +} + +/** + * Gets the alt text for an image or video. + * + * It checks: + * 1. Uses the AltText from the asset if it exists + * 2. Falls back to your custom text if AltText is null/undefined + * 3. Returns empty string if no alt text or fallback is available + * + * Note: By default, this returns an empty string when no alt text is available, which marks + * the image as decorative. To avoid accidentally creating inaccessible content, always provide + * a fallback or ensure your assets have AltText set in the CMS. + * + * @param input - Your image or video reference + * @param fallback - Text to use if there's no AltText (defaults to empty string) + * @returns The alt text to use + * + * @example + * With a AltText present in the asset: + * ```tsx + * const { getAlt } = damAssets(opti); + * {getAlt(opti.image)} + * ``` + * + * @example + * Using a custom fallback: + * ```tsx + * const { getAlt } = damAssets(opti); + * {getAlt(opti.image, + * ``` + * + * @example + * Explicitly marking an image as decorative: + * ```tsx + * const { getAlt } = damAssets(opti); + * {getAlt(opti.icon)} // Will be alt="" if no AltText exists + * ``` + */ +export function getAlt( + input: InferredContentReference | null | undefined, + fallback: string = '' +): string { + if (!input) return fallback; + + if (input.item && 'AltText' in input.item) { + const rawAlt = input.item.AltText; + // Empty strings also trigger fallback + return rawAlt || fallback; + } + + return fallback; +} + +/** + * A helper that gives you pre-configured getSrcset and getAlt functions. + * + * Use this when you want to avoid passing opti around everywhere. + * The returned getSrcset already knows about your preview tokens. + * + * @param opti - Your content object + * @returns Helper functions for working with your images + * + * @example + * ```tsx + * import { damAssets } from '@optimizely/cms-sdk'; + * + * export default function MyComponent({ opti }) { + * const { getSrcset, getAlt } = damAssets(opti); + * + * return ( + * {getAlt(opti.image, + * ); + * } + * ``` + * + * @example + * Works great with multiple images: + * ```tsx + * const { getSrcset, getAlt } = damAssets(opti); + * + * {getAlt(opti.heroImage)} + * {getAlt(opti.thumbnail, + * ``` + */ +export function damAssets>( + opti: T & { __context?: { preview_token?: string } } +) { + return { + getSrcset: (property: InferredContentReference | null | undefined) => + getSrcset(opti, property), + getAlt, + }; +} diff --git a/packages/optimizely-cms-sdk/src/util/baseTypeUtil.ts b/packages/optimizely-cms-sdk/src/util/baseTypeUtil.ts index d72aeca3..1d605b88 100644 --- a/packages/optimizely-cms-sdk/src/util/baseTypeUtil.ts +++ b/packages/optimizely-cms-sdk/src/util/baseTypeUtil.ts @@ -49,6 +49,13 @@ export function isBaseMediaType(key: string): key is MediaStringTypes { export const CONTENT_URL_FRAGMENT = 'fragment ContentUrl on ContentUrl { type default hierarchical internal graph base }'; +export const CONTENT_REFERENCE_ITEM_FRAGMENTS = [ + 'fragment PublicImageAsset on cmp_PublicImageAsset { Url Title AltText Description Renditions { Id Name Url Width Height } FocalPoint { X Y } Tags { Guid Name } }', + 'fragment PublicVideoAsset on cmp_PublicVideoAsset { Url Title AltText Description Renditions { Id Name Url Width Height } Tags { Guid Name } }', + 'fragment PublicRawFileAsset on cmp_PublicRawFileAsset { Url Title Description Tags { Guid Name } }', + 'fragment ContentReferenceItem on ContentReference { item { ...PublicImageAsset ...PublicVideoAsset ...PublicRawFileAsset } }', +]; + const COMMON_FRAGMENTS = [ 'fragment MediaMetadata on MediaMetadata { mimeType thumbnail content }', 'fragment ItemMetadata on ItemMetadata { changeset displayOption }', @@ -57,16 +64,24 @@ const COMMON_FRAGMENTS = [ 'fragment IContentMetadata on IContentMetadata { key locale fallbackForLocale version displayName url {...ContentUrl} types published status created lastModified sortOrder variation ...MediaMetadata ...ItemMetadata ...InstanceMetadata }', 'fragment _IContent on _IContent { _id _metadata {...IContentMetadata} }', ]; + const COMMON_FIELDS = '..._IContent'; /** * Generates and adds fragments for base types + * @param damEnabled - Whether DAM features are enabled * @returns { fields, extraFragments } */ -export function buildBaseTypeFragments() { +export function buildBaseTypeFragments(damEnabled: boolean = false) { + const extraFragments = [...COMMON_FRAGMENTS]; + + if (damEnabled) { + extraFragments.push(...CONTENT_REFERENCE_ITEM_FRAGMENTS); + } + return { fields: [COMMON_FIELDS], - extraFragments: COMMON_FRAGMENTS, + extraFragments, }; } diff --git a/packages/optimizely-cms-sdk/src/util/preview.ts b/packages/optimizely-cms-sdk/src/util/preview.ts new file mode 100644 index 00000000..6d0e9566 --- /dev/null +++ b/packages/optimizely-cms-sdk/src/util/preview.ts @@ -0,0 +1,13 @@ +/** + * Appends the preview token to the given URL as a query parameter. + * If the preview token is not provided, the original URL is returned. + * + * @param url - The original URL. + * @param previewToken - The preview token to append. + * @returns The URL with the preview token appended as a query parameter. + */ +export const appendToken = (url: string, previewToken?: string): string => { + if (!previewToken) return url; + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}preview_token=${previewToken}`; +}; diff --git a/samples/nextjs-template/src/components/AboutUs.tsx b/samples/nextjs-template/src/components/AboutUs.tsx index 650cc213..5575cb1a 100644 --- a/samples/nextjs-template/src/components/AboutUs.tsx +++ b/samples/nextjs-template/src/components/AboutUs.tsx @@ -1,7 +1,6 @@ -import { contentType, Infer } from '@optimizely/cms-sdk'; +import { contentType, damAssets, Infer } from '@optimizely/cms-sdk'; import { RichText, ElementProps } from '@optimizely/cms-sdk/react/richText'; import { getPreviewUtils } from '@optimizely/cms-sdk/react/server'; -import Image from 'next/image'; export const AboutUsContentType = contentType({ key: 'AboutUs', @@ -33,12 +32,20 @@ const customHeadingTwo = (props: ElementProps) => { }; export default function AboutUs({ opti }: AboutUsProps) { - const { src } = getPreviewUtils(opti); + const { src } = getPreviewUtils(opti); + const { getSrcset, getAlt } = damAssets(opti); + return (
- {opti?.image?.url?.default && ( + {opti.image && (
- + {/* eslint-disable-next-line @next/next/no-img-element */} + {getAlt(opti.image,
)}

{opti.heading}

diff --git a/samples/nextjs-template/src/components/SmallFeature.tsx b/samples/nextjs-template/src/components/SmallFeature.tsx index 3b3a68ac..8cd18213 100644 --- a/samples/nextjs-template/src/components/SmallFeature.tsx +++ b/samples/nextjs-template/src/components/SmallFeature.tsx @@ -1,4 +1,4 @@ -import { contentType, Infer } from '@optimizely/cms-sdk'; +import { contentType, damAssets, Infer } from '@optimizely/cms-sdk'; import { getPreviewUtils } from '@optimizely/cms-sdk/react/server'; export const SmallFeatureContentType = contentType({ @@ -25,13 +25,18 @@ type Props = { export default function SmallFeature({ opti }: Props) { const { pa, src } = getPreviewUtils(opti); + const { getAlt } = damAssets(opti); return (

{opti.heading}

{opti.image?.url?.default && (
- + {getAlt(opti.image,
)}