diff --git a/packages/optimizely-cms-sdk/package.json b/packages/optimizely-cms-sdk/package.json index 5ef820a8..1cd3a711 100644 --- a/packages/optimizely-cms-sdk/package.json +++ b/packages/optimizely-cms-sdk/package.json @@ -11,8 +11,17 @@ "author": "", "license": "ISC", "packageManager": "pnpm@10.7.0", + "peerDependencies": { + "react": "^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, "devDependencies": { "@types/node": "^22.13.14", + "@types/react": "^19", "typescript": "^5.8.2", "vitest": "^3.1.1" } diff --git a/packages/optimizely-cms-sdk/src/graph/createQuery.ts b/packages/optimizely-cms-sdk/src/graph/createQuery.ts index cb81c994..3712a92f 100644 --- a/packages/optimizely-cms-sdk/src/graph/createQuery.ts +++ b/packages/optimizely-cms-sdk/src/graph/createQuery.ts @@ -84,6 +84,7 @@ export async function createQuery(contentType: string, customImport: Importer) { query FetchContent($filter: _ContentWhereInput) { _Content(where: $filter) { item { + __typename ...${contentType} } } diff --git a/packages/optimizely-cms-sdk/src/render/component-registry.ts b/packages/optimizely-cms-sdk/src/render/component-registry.ts new file mode 100644 index 00000000..6b0b0883 --- /dev/null +++ b/packages/optimizely-cms-sdk/src/render/component-registry.ts @@ -0,0 +1,21 @@ +export type ComponentResolver = + | Record + | ((contentType: string) => C); + +/** A registry mapping content type names and components */ +export class ComponentRegistry { + resolver: ComponentResolver; + + constructor(resolver: ComponentResolver) { + this.resolver = resolver; + } + + /** Returns the component given its content type name. Returns `undefined` if not found */ + getComponent(contentType: string): T { + if (typeof this.resolver === 'object') { + return this.resolver[contentType]; + } else { + return this.resolver(contentType); + } + } +} diff --git a/packages/optimizely-cms-sdk/src/render/react.tsx b/packages/optimizely-cms-sdk/src/render/react.tsx new file mode 100644 index 00000000..84b215d4 --- /dev/null +++ b/packages/optimizely-cms-sdk/src/render/react.tsx @@ -0,0 +1,36 @@ +'use server'; +import type React from 'react'; +import { ComponentRegistry, ComponentResolver } from './component-registry'; + +type ComponentType = React.ComponentType; + +let componentRegistry: ComponentRegistry; + +type InitOptions = { + resolver: ComponentResolver; +}; + +type Props = { + opti: { + __typename: string; + }; +}; + +export function initReactComponentRegistry(options: InitOptions) { + componentRegistry = new ComponentRegistry(options.resolver); +} + +export async function OptimizelyComponent({ opti, ...props }: Props) { + if (!componentRegistry) { + throw new Error('You should call `initReactComponentRegistry` first'); + } + + const contentType = opti.__typename; + const Component = await componentRegistry.getComponent(contentType); + + if (!Component) { + return
No component found for content type {contentType}
; + } + + return ; +} diff --git a/packages/optimizely-cms-sdk/tsconfig.json b/packages/optimizely-cms-sdk/tsconfig.json index a3cb5253..a7cdd61b 100644 --- a/packages/optimizely-cms-sdk/tsconfig.json +++ b/packages/optimizely-cms-sdk/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "jsx": "react-jsx", "target": "es2016", "module": "commonjs", "moduleResolution": "node", @@ -7,7 +8,7 @@ "declaration": true, "sourceMap": true, "outDir": "./dist", - "esModuleInterop": false, + "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa3a0973..b1b69996 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,10 +52,17 @@ importers: version: 5.8.2 packages/optimizely-cms-sdk: + dependencies: + react: + specifier: ^19.0.0 + version: 19.1.0 devDependencies: '@types/node': specifier: ^22.13.14 version: 22.13.14 + '@types/react': + specifier: ^19 + version: 19.0.12 typescript: specifier: ^5.8.2 version: 5.8.2 diff --git a/samples/nextjs-template/src/app/render/[slug]/page.tsx b/samples/nextjs-template/src/app/render/[slug]/page.tsx new file mode 100644 index 00000000..c8679d44 --- /dev/null +++ b/samples/nextjs-template/src/app/render/[slug]/page.tsx @@ -0,0 +1,32 @@ +import { GraphClient } from 'optimizely-cms-sdk/dist/graph'; +import { + initReactComponentRegistry, + OptimizelyComponent, +} from 'optimizely-cms-sdk/dist/render/react'; +import React from 'react'; + +initReactComponentRegistry({ + resolver(contentType) { + return React.lazy(() => import(`../../../components/${contentType}.tsx`)); + }, +}); + +async function myImport(contentType: string) { + return import(`../../../components/${contentType}.tsx`).then( + (r) => r.ContentType + ); +} + +type Props = { + params: Promise<{ + slug: string; + }>; +}; + +export default async function Page({ params }: Props) { + const { slug } = await params; + const client = new GraphClient(process.env.GRAPH_SINGLE_KEY!, myImport); + const c = await client.fetchContent(`/${slug}/`); + + return ; +} diff --git a/samples/nextjs-template/src/components/Landing.tsx b/samples/nextjs-template/src/components/Landing.tsx index c252461d..d532388f 100644 --- a/samples/nextjs-template/src/components/Landing.tsx +++ b/samples/nextjs-template/src/components/Landing.tsx @@ -1,4 +1,5 @@ -import { contentType } from 'optimizely-cms-sdk'; +import { contentType, Infer } from 'optimizely-cms-sdk'; +import { OptimizelyComponent } from 'optimizely-cms-sdk/dist/render/react'; export const ContentType = contentType({ key: 'Landing', @@ -27,3 +28,19 @@ export const ContentType = contentType({ }, }, }); + +type Props = { + opti: Infer; +}; + +export default function LandingComponent({ opti }: Props) { + return ( +
+

{opti.heading}

+

{opti.summary}

+ {opti.sections.map((section, i) => ( + + ))} +
+ ); +} diff --git a/samples/nextjs-template/src/components/LandingSection.tsx b/samples/nextjs-template/src/components/LandingSection.tsx index b901b4d8..4d7afaa5 100644 --- a/samples/nextjs-template/src/components/LandingSection.tsx +++ b/samples/nextjs-template/src/components/LandingSection.tsx @@ -1,4 +1,4 @@ -import { contentType, displayTemplate } from 'optimizely-cms-sdk'; +import { contentType, displayTemplate, Infer } from 'optimizely-cms-sdk'; export const ContentType = contentType({ key: 'LandingSection', @@ -11,13 +11,6 @@ export const ContentType = contentType({ subtitle: { type: 'string', }, - features: { - type: 'array', - items: { - type: 'content', - allowedTypes: ['SmallFeatureGrid'], - }, - }, }, }); @@ -25,7 +18,7 @@ export const DisplayTemplate = displayTemplate({ key: 'LandingSectionDisplayTemplate', isDefault: true, displayName: 'LandingSectionDisplayTemplate', - contentType: 'LandingSection', + baseType: 'component', settings: { background: { editor: 'select', @@ -44,3 +37,16 @@ export const DisplayTemplate = displayTemplate({ }, }, }); + +type Props = { + opti: Infer; +}; + +export default function LandingSection({ opti }: Props) { + return ( +
+

{opti.heading}

+

{opti.subtitle}

+
+ ); +}