import { addConfigToUrl, createConfigMessage } from '@stack-spot/portal-extension-commons' import { useThemeName } from '@stack-spot/portal-theme' import { useLanguage } from '@stack-spot/portal-translate' import { useEffect, useRef } from 'react' import { MessageHandler } from './MessageHandler' import { CURRENT_VIEW_ATTRIBUTE } from './constants' import { extensionMenu } from './extension-menu' import { extensionRegister } from './extension-register' import { extensionTestRegister } from './extension-test-register' const getUrl = (slug: string) => { try { const extensionUrl = extensionTestRegister.getExtension(slug)?.url ?? extensionRegister.getExtension(slug)?.url const url = new URL(extensionUrl!) return `${url}` } catch (error) { throw new Error(`Extension with slug "${slug}" requires a valid URL. Please ensure the extension is registered correctly.`) } } interface Props { slug: string, /** * The path to load within the extension. */ path?: string, /** * The path of the parent page, i.e. the path of the page that includes this iframe. * * This is used for calculating subroutes. By setting this, you effectively enable url tracking in the browser navigation bar. * * @example * "/extension/my-extension-slug" */ pathToParent?: string, version?: string, /** * If true, will use hashes for controlling the urls in the extension (/#/). * @default true */ useHashOnUrl?: boolean, /** * When true, allows the extension to control the context (secondary) menu of the parent. * * Only one extension can control the menu at a a time. If you have 2 extensions rendered and both have "enableMenu" set to true, only * the last one will have control over the menu. */ enableMenu?: boolean, /** * An extension may submit data. The submit event can be listened to by providing this function. */ onSubmit?: (data: Record) => void, /** * An extension may trigger the "cancel" event. This event can be listened to by providing this function. */ onCancel?: () => void, /** * A initial dataset to the extension. The extension can get this by calling "getInitialData()". */ data?: Record, style?: React.CSSProperties, className?: string, } function removeBaseUrlFromPath(baseUrl: string, path: string) { let i = 0 let j = 0 while (i < baseUrl.length) { const i0 = i j = 0 while (i < baseUrl.length && j < path.length && baseUrl[i] === path[j]) { i++ j++ } if (i === i0) i++ } return (j > 0 && (path[j] === '/' || path[j + 1] === '/')) ? path.substring(j) : path } /** * Renders an Extension. * * ATTENTION: if the slug can change during the execution, provide the slug as the key for this component, otherwise, some features won't * work for the second extension. * @param props {@link Props} * @returns the component. */ export const Extension = ( { slug, version, path = '', pathToParent, useHashOnUrl = true, enableMenu, onCancel, onSubmit, data, style, className }: Props, ) => { const ref = useRef(null) const shouldAdaptHeight = !style?.height || style.height === 'auto' const messageHandler = useRef() const themeName = useThemeName() const language = useLanguage() const url = getUrl(slug) const fixedPath = removeBaseUrlFromPath(url, path).replace(/\/?#\/?/, '') const pathFromParent = location.href.replace(`${location.origin}${pathToParent?.replace('#/', '')?.replace(/\/$/, '')}`, '') const currentIFrameView = ref.current?.getAttribute(CURRENT_VIEW_ATTRIBUTE) // the path may repeat the last part of "url". This happens when the extension is not in the root of a domain. const src = addConfigToUrl( new URL(`${url}/${useHashOnUrl ? '#' : ''}${pathToParent ? pathFromParent : fixedPath}`.replace(/\/\//, '/')), currentIFrameView && currentIFrameView !== pathFromParent ? ref.current?.getAttribute('src') ?? '' : '', ) useEffect(() => { if (ref.current) messageHandler.current = new MessageHandler({ iframe: ref.current, apiUrl: url, pathToParent, slug, version, shouldAdaptHeight, onSubmit, onCancel, }) return () => messageHandler.current?.destroy() }, [ref.current]) function sendConfig() { messageHandler.current?.sendConfig(createConfigMessage({ themeName, language, data })) } useEffect(() => sendConfig, [themeName, language]) useEffect(() => { if (enableMenu) { extensionMenu.controlledBy = { slug, url } return () => { extensionMenu.set(undefined) extensionMenu.controlledBy = undefined } } }, []) return