import { getDatasource, registerDatasource } from './datasources.js' import { XMTypes } from './datatypes.js' import { getSchemaProperties, JSONSchema, ObjectSchema, parseValue, transformSchema, transformUiSchema, UISchema } from './schema.js' import { objectKeysToKebabCase, toKebabCase } from './utils.js' /** Normalized BYOC component */ export interface MinimalComponentProps { componentName: string uid?: string datasources?: Record [prop: string]: any } export interface ContextProperties { [key: string]: any } /** * Component use [JSON Schema]{@link https://json-schema.org}, which is a declarative language that allows you to * annotate and validate JSON documents. * * In context of external components it is used to describe properties that component accept. It is used for documenting * options, parsing the values and generating the configuration UI. It is possible to provide [UI * Schema]{@link https://rjsf-team.github.io/react-jsonschema-form/docs/api-reference/uiSchema} as well to fine tune the * form UI. * * @param {string} title Component name as presented to the user. * @param {JSONSchema['properties']} properties Object of properties. * @param {JSONSchema['type']} properties[].type JSON Schema type of a property. * @param {string} [properties[].title] Optional title displayed to the user. * @param {string} [group] Optional Image to be displayed in the UI. * @param {string} [thumbnail] Optional title displayed to the user. * @param {UISchema} [ui] Optional component groupping. * @see https://json-schema.org * @see https://rjsf-team.github.io/react-jsonschema-form/docs/api-reference/uiSchema * @example * * { * // Name of a component as it will be presented to the user * title: 'My Component', * // Optional: Thumbnail image for component to be displayed in UI * thumbnail: 'http://examaple.com/component.jpg', * // Optional: Group components by name * group: 'Assorted components', * // Definitions of three configurable properties that MyComponent accepts * properties: { * // Color is a string, but only Red/Green/Blue are allowed * color: { type: 'string', enum: ['red', 'green', 'blue'] }, * // Text is a property labelled as "Text summary" * text: { type: 'string', title: 'Text summary' }, * // Count is a number with 1 as default value * count: { type: 'number', default: 1 } * }, * // Optional UI schema customization * ui: }, * // Color and text area required properties * required: ['color', 'text'] * }) * */ type UppercaseLetters = | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z' /** Custom types start with uppercase letter */ export type ExternalComponentCustomType = keyof XMTypes | `${UppercaseLetters}${string}` export type ExternalComponentSchemaProperty = ObjectSchema & { isHidden?: boolean; ui?: UISchema } export type ExternalComponentOptions = Omit, 'properties'> & { /** Optional unique identifier of a component (falls back to name if not provided) */ id?: string /** Internal name of a component */ name: string /** Description of a component */ description?: string /** Title of a component as it will be presented to the user */ title?: string /** Object containing JSONSchema definitions of properties for instances of the component. */ properties?: { /** Single property */ [propertyName in keyof T]?: Omit & { /** Type of the property (string, array, object, number, integer, null) */ type: JSONSchema['type'] | ExternalComponentCustomType /** Optional title displayed to the user */ title?: JSONSchema['title'] /** Hide property configuration from UI */ isHidden?: boolean /** Default value for a property, should be valid and of correct type */ default?: any } } /** Optional thumbnail to be displayed in the UI when listing components */ thumbnail?: string /** Name of a collection to group components by (default: Ungroupped) */ group?: string /** Hide component from the UI */ isHidden?: boolean /** List of datasource IDs that component supports */ datasourceIds?: string[] /** Key/value list of links to be displayed in BYOC Marketplace */ links?: Record /** Key/value list of links to be displayed in BYOC Marketplace */ meta?: Record /** * UI Schema providing customization of form UI. * * @see https://json-schema.org */ ui?: UISchema } export type ExternalComponentHandler

= | React.ComponentType

| ((props: P) => Promise) | WebComponent | null export type WebComponent = typeof WebComponent export function normalizeOptions

( options: ExternalComponentOptions

, component?: ExternalComponentHandler

, defaults?: any ) { /** * Usually property schema *is* the options, and ui schema is in `options.ui` property. This function normalizes schemas, * as stores them as `uiSchema` and `schema` properties respectively. The registerComponent() call can be made with previously * normalized properties (e.g. in parent frame), so it has to supports those as well. */ const { thumbnail = 'https://feaasstatic.blob.core.windows.net/assets/thumbnails/byoc.svg', name, id = options.name, group = null, ui, isHidden = false, datasourceIds = [], links = {}, meta = {}, uiSchema: explicitUISchema, schema: explicitSchema, ...schemaOptions } = options const schemaBase = explicitSchema || schemaOptions || {} const useSchemaBase = explicitUISchema || ui || {} const schema = transformSchema( { description: 'External component', ...schemaBase, type: 'object' }, defaults ) as ObjectSchema const uiSchema = transformUiSchema(useSchemaBase, schema.properties || {}) return { component: component || (() => null), name, schema, uiSchema, thumbnail, group: group || 'Default collection', isHidden, id, datasourceIds, links, meta, title: schema?.title || schemaOptions?.title || name } } export type ExternalComponent

= ReturnType> var registrationCallback: ReturnType var componentRegistrationAcknowledged = false var componentRegistrationRetryCount = 0 var componentAckListenerAdded = false // Extended retry configuration to ensure reliable component registration // Total retry window: ~30 seconds (150 retries × 200ms) // This addresses race conditions where parent window listener may not be ready immediately const COMPONENT_MAX_RETRY_ATTEMPTS = 150 const COMPONENT_RETRY_INTERVAL = 200 // ms // Shim in case it's required in node.js environment export const WebComponent = (typeof HTMLElement != 'undefined' ? HTMLElement : // @ts-ignore typeof windowJSDOM != 'undefined' ? // @ts-ignore (windowJSDOM.HTMLElement as typeof HTMLElement) : (class { setAttribute() {} } as unknown as typeof HTMLElement)) as any as typeof HTMLElement // Store registered components in a global variable BYOCComponents for external access // On the clientside the component registry is shared via global variable, on server it doesnt to avoid breaking next.js export const registered: Record = typeof window != 'undefined' ? (window.BYOCComponents ||= {}) : {} /** * Register React component to be renderable as Sitecore component (in Components and Pages). Properties are defined as * {@link https://json-schema.org JSON Schema}, from which a configuration form will be produced using * {@link https://rjsf-team.github.io/react-jsonschema-form/ RJSF library}. The library allows passing a * {@link https://rjsf-team.github.io/react-jsonschema-form/docs/api-reference/uiSchema UI Schema} that allows tight * customization of form widgets, styles and behavior. * * Schema properties supports `default` option which provides a fallback value. Alternatively default properties can be * passed as plain javascript object as third argument to `registerComponent` call. * * It is possible to generate same component and schema with different defaults, as separate components by altering the * `id` property. This is used for to expand generic component like Form into different specific Form instances. * * @param {React.ComponentType} component React component that will be rendered. * @param {string} options.title Component name as presented to the user. * @param {JSONSchema['properties']} options.properties Object of properties. * @param {JSONSchema['type']} options.properties[].type JSON Schema type of a property. * @param {string} [options.properties[].title] Optional title displayed to the user. * @param {string} [options.group] Optional Image to be displayed in the UI. * @param {string} [options.thumbnail] Optional title displayed to the user. * @param {string} [options.isHidden] Optionally hide from the UI. * @param {UISchema} [options.ui] Optional component groupping. * @param {string[]} [options.datasourceIds] List of datasource id component requires. * @see https://rjsf-team.github.io/react-jsonschema-form/docs/api-reference/uiSchema * @see https://json-schema.org * @example * * FEAAS.External.registerComponent( * MyComponent, * { * // Name of a component as it will be used internally * name: 'MyComponent', * // Title of a component as it will be presented to the user * title: 'My Component', * // Optional: Thumbnail image for component to be displayed in UI * thumbnail: 'http://examaple.com/component.jpg', * // Optional: Group components in the UI * group: 'Assorted components', * // Definitions of three configurable properties that MyComponent accepts * properties: { * // Color is a string, but only Red/Green/Blue are allowed * color: { type: 'string', enum: ['red', 'green', 'blue'] }, * // Text is a property labelled as "Text summary" * text: { type: 'string', title: 'Text summary' }, * // Count is a number with 1 as default value * count: { type: 'number', default: 1 } * }, * // Optional UI schema customization * ui: { * // Present color choices as radio buttons * color: { 'ui:widget': 'radio' }, * // Text is a textarea with 5 rows * text: { 'ui:widget': 'textarea', 'ui:options': { rows: 5 } }, * // Count is a number field with spinner button * count: { 'ui:widget': 'updown' } * }, * // Color and text area required properties * required: ['color', 'text'] * }, * { text: 'Default text' } * ) * */ export function registerComponent( component: ExternalComponentHandler, options: ExternalComponentOptions, defaults: any = {} ) { if (!options?.name) throw new Error( 'Could not register external component. Please make sure you provide a name in the options' + JSON.stringify(options) ) const normalizedOptions = normalizeOptions(options, component, defaults) registered[normalizedOptions.id] = normalizedOptions if (isWebComponent(component)) { BYOCRegistration.register('byoc-' + toKebabCase(options.name), undefined, component) } setRegistrationCallback() } export function isWebComponent(object: any): object is WebComponent { return object && 'prototype' in object && 'setAttribute' in object.prototype } export type RegisteredComponents = { [id: string]: ExternalComponent } /** Transform properties to proper types and merge them with default values */ export function getComponentProperties( id: string | ExternalComponent, props: Record = {} ): Record { const schema = getComponent(id)?.schema return schema ? getSchemaProperties(schema, props) : props } export function getComponentConfigurablePropertyNames(id: string | ExternalComponent): string[] { const definition = getComponent(id) return Object.keys(definition?.schema.properties || {}).filter((prop) => { return definition?.uiSchema?.[prop]?.['ui:widget'] != 'hidden' }) } /** * Resolve a component by its id in one of two formats: * - Simple id like `ComponentName` returns the registered component * - Complex id like `ComponentName?prop=value` returns a combination of: * - `ComponentName` component if registered * - `ComponentName?prop=value` component if registered * - Default values provided in query string * * The latter approach allows registering a generic component under simple name, and its overloads * which will be presented as individual components to the user. */ export function getComponent(id: string | ExternalComponent) { if (typeof id != 'string') { if (id && 'schema' in id) return id throw new Error(`Component name should be a string, got ${typeof id}`) } const [name, query] = id.split('?') var base = registered[name] // Deal with query string if (query) { // if component is registered with query string, merge two component definitions const overload = registered[id] if (!overload && !base) return null if (overload) base = { ...base, ...overload, component: overload.component || base?.component } // merge query string as default values query.split(/\&/g).forEach((pair) => { const [k, v] = pair.split('=') const propertyDefinition = base.schema.properties?.[k] || { type: 'string' } // merge in k/v pair as default value base = { ...base, schema: { ...base.schema, properties: { ...base.schema.properties, [k]: { ...propertyDefinition, default: parseValue(decodeURIComponent(v), propertyDefinition.type) } } }, uiSchema: { ...base.uiSchema, // hide preconfigured properties [k]: { ...base.uiSchema[k], 'ui:widget': base.uiSchema[k]?.['ui:widget'] ?? 'hidden' } } } }) } return base } /** * Retrieves properties for a component, including context properties component's defaults. * * @param props - The component props. * @returns An object containing the attributes, properties, and merged properties. */ export function getMergedComponentProperties(props: MinimalComponentProps) { const { componentName, className, fallbackWrapper, fallback, suppressHydrationWarning, _dynamic, datasources, ...givenProps } = props try { var parsedDatasources = typeof datasources == 'string' ? JSON.parse(datasources) : datasources } catch (e) {} // find first datasources with object data, use it as a source of properties const dataProperties: any = Object.values(parsedDatasources || {}).find( (v) => v && !Array.isArray(v) && Object.keys(v).length > 0 ) const properties = { ...dataProperties, ...getComponentProperties(componentName, { ...dataProperties, ...givenProps }), ...(parsedDatasources ? { datasources: parsedDatasources } : {}) } const attributes = { 'data-external-id': componentName, ...objectKeysToKebabCase(properties), suppressHydrationWarning: true, class: className } serializedContextProperties.forEach((key) => { Object.assign(attributes, { [toKebabCase(key)]: contextProperties[key] }) }) // serialize json properties Object.keys(attributes).forEach((key) => { const value = attributes[key as keyof typeof attributes] if (value && typeof value == 'object' && key != 'class' && key != 'children') { try { Object.assign(attributes, { [key]: JSON.stringify(value) }) } catch (e) { delete attributes[key as keyof typeof attributes] } } if (typeof value == 'function' || value == null) { delete attributes[key as keyof typeof attributes] } }) return { /** HTML attributes in kebab case, including strings and explicitly passed objects in json format */ attributes, /** React properties combined with datasources */ properties, /** React properties combined with datasources and context properties */ merged: { ...contextProperties, ...properties } } } /** * Set up acknowledgment listener for component registration. * This listener receives confirmation from the parent window that components were registered. */ function setupComponentAckListener() { if (typeof window === 'undefined' || componentAckListenerAdded) return componentAckListenerAdded = true window.addEventListener('message', (event) => { try { const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data if (data.action === 'register-components-ack') { componentRegistrationAcknowledged = true componentRegistrationRetryCount = 0 clearTimeout(registrationCallback) } } catch (e) { // Ignore parse errors from other messages } }) } /** * Sends component registration to parent window with retry logic. * Retries up to COMPONENT_MAX_RETRY_ATTEMPTS times until acknowledgment is received. */ export function setRegistrationCallback() { clearTimeout(registrationCallback) if (typeof window !== 'undefined' && window.parent !== window) { // Set up acknowledgment listener on first call setupComponentAckListener() // Reset acknowledgment state when new registration is triggered componentRegistrationAcknowledged = false componentRegistrationRetryCount = 0 const sendRegistration = () => { // Stop if already acknowledged or max retries reached if (componentRegistrationAcknowledged) { return } if (componentRegistrationRetryCount >= COMPONENT_MAX_RETRY_ATTEMPTS) { // Max retries reached, stop trying but don't log error to avoid noise return } // Send components to parent window window.parent?.postMessage( JSON.stringify({ action: 'register-components', data: Object.values(registered) }), '*' ) componentRegistrationRetryCount++ // Schedule next retry if not acknowledged if (!componentRegistrationAcknowledged && componentRegistrationRetryCount < COMPONENT_MAX_RETRY_ATTEMPTS) { registrationCallback = setTimeout(sendRegistration, COMPONENT_RETRY_INTERVAL) } } // Initial delay before first send (maintains original behavior) registrationCallback = setTimeout(sendRegistration, 30) } } setRegistrationCallback() // Register schemas passed as // It extends the web component in case it already was registered by different name export class BYOCRegistration extends WebComponent { connectedCallback() { try { ;(JSON.parse(String(this.getAttribute('components'))) as any[]).forEach((component) => { if (!getComponent(component.id)) registerComponent(null, component) }) ;(JSON.parse(String(this.getAttribute('datasources'))) as any[]).forEach((datasource) => { if (!getDatasource(datasource.id)) registerDatasource((settings) => settings, datasource) }) } catch (e) {} } static register(tagName: string, win?: Window, component: WebComponent = this) { if (win == null) win = typeof window != 'undefined' ? window : undefined if (win && !win.customElements.get(tagName)) { win.customElements.define(tagName, class extends component {}) } } } export var contextProperties: ContextProperties = {} export function setContextProperties(props: ContextProperties) { contextProperties = props } export const serializedContextProperties = ['sitecoreEdgeUrl', 'sitecoreEdgeContextId'] BYOCRegistration.register('byoc-registration') declare global { namespace JSX { interface IntrinsicElements { 'byoc-registration': { suppressHydrationWarning?: boolean components: string datasources: string } } } interface Window { BYOCComponents: RegisteredComponents BYOCComponentsFrozen: boolean BYOCWebComponent: typeof HTMLElement } }